diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml index c13eff127..c2ee3dfb3 100644 --- a/.github/workflows/auto-close-duplicates.yml +++ b/.github/workflows/auto-close-duplicates.yml @@ -12,17 +12,25 @@ jobs: permissions: contents: read issues: write + id-token: write steps: - name: Checkout repository uses: actions/checkout@v5 + - name: Generate Marvin App token + id: marvin-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.MARVIN_APP_ID }} + private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} + - name: Install uv uses: astral-sh/setup-uv@v7 - name: Auto-close duplicate issues run: uv run scripts/auto_close_duplicates.py env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.marvin-token.outputs.token }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} diff --git a/.github/workflows/auto-close-needs-mre.yml b/.github/workflows/auto-close-needs-mre.yml new file mode 100644 index 000000000..98d26ccff --- /dev/null +++ b/.github/workflows/auto-close-needs-mre.yml @@ -0,0 +1,36 @@ +name: Auto-close needs MRE issues +description: Auto-closes issues that need minimal reproducible examples after 7 days of author inactivity +on: + schedule: + - cron: "0 9 * * *" # Run daily at 9 AM UTC + workflow_dispatch: + +jobs: + auto-close-needs-mre: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Generate Marvin App token + id: marvin-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.MARVIN_APP_ID }} + private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Auto-close needs MRE issues + run: uv run scripts/auto_close_needs_mre.py + env: + GITHUB_TOKEN: ${{ steps.marvin-token.outputs.token }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 80c39dfd1..892f8629d 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,4 +1,4 @@ -name: Run tests +name: Tests env: # enable colored output diff --git a/README.md b/README.md index e96b243e4..59f68e37d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ *Made with ☕️ by [Prefect](https://www.prefect.io/)* [![Docs](https://img.shields.io/badge/docs-gofastmcp.com-blue)](https://gofastmcp.com) +[![Discord](https://img.shields.io/badge/community-discord-5865F2?logo=discord&logoColor=white)](https://discord.gg/uu8dJCgttd) [![PyPI - Version](https://img.shields.io/pypi/v/fastmcp.svg)](https://pypi.org/project/fastmcp) [![Tests](https://github.com/jlowin/fastmcp/actions/workflows/run-tests.yml/badge.svg)](https://github.com/jlowin/fastmcp/actions/workflows/run-tests.yml) [![License](https://img.shields.io/github/license/jlowin/fastmcp.svg)](https://github.com/jlowin/fastmcp/blob/main/LICENSE) @@ -70,6 +71,8 @@ There are two ways to access the LLM-friendly documentation: - [`llms.txt`](https://gofastmcp.com/llms.txt) is essentially a sitemap, listing all the pages in the documentation. - [`llms-full.txt`](https://gofastmcp.com/llms-full.txt) contains the entire documentation. Note this may exceed the context window of your LLM. +**Community:** Join our [Discord server](https://discord.gg/uu8dJCgttd) to connect with other FastMCP developers and share what you're building. + --- diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..61e28ebc5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,16 @@ +# Security Policy + +## Supported Versions + +FastMCP v2.x receives security updates. Earlier versions are no longer supported. + +| Version | Supported | +| ------- | ------------------ | +| 2.x | :white_check_mark: | +| < 2.0 | :x: | + +## Reporting a Vulnerability + +Please report security vulnerabilities privately using [GitHub's security advisory feature](https://github.com/jlowin/fastmcp/security/advisories/new). + +Do not open public issues for security concerns. diff --git a/docs/community/showcase.mdx b/docs/community/showcase.mdx index c0a9e3545..fe8ec810d 100644 --- a/docs/community/showcase.mdx +++ b/docs/community/showcase.mdx @@ -6,6 +6,12 @@ icon: 'users' import { YouTubeEmbed } from '/snippets/youtube-embed.mdx' +## Join the Community + + + Connect with other FastMCP developers, share your projects, and discuss ideas. + + ## Featured Projects Discover exemplary MCP servers and implementations created by our community. These projects demonstrate best practices and innovative uses of FastMCP. diff --git a/docs/docs.json b/docs/docs.json index 758eca70f..625faac80 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -9,10 +9,10 @@ "dark": "#222831", "light": "#EEEEEE" }, - "decoration": "windows" + "decoration": "gradient" }, "banner": { - "content": "Host your server on [FastMCP Cloud](https://fastmcp.cloud) for free!" + "content": "Join the [FastMCP community](https://discord.gg/uu8dJCgttd)!" }, "colors": { "dark": "#f72585", @@ -26,15 +26,23 @@ ] }, "description": "The fast, Pythonic way to build MCP servers and clients.", + "errors": { + "404": { + "description": "You\u2019ve wandered outside the context.", + "redirect": false, + "title": "Don't panic." + } + }, "favicon": { "dark": "/assets/brand/favicon.svg", "light": "/assets/brand/favicon.svg" }, "footer": { "socials": { - "bluesky": "https://bsky.app/profile/jlowin.dev", + "discord": "https://discord.gg/uu8dJCgttd", "github": "https://github.com/jlowin/fastmcp", - "x": "https://x.com/jlowin" + "website": "https://prefect.ai", + "x": "https://x.com/fastmcp" } }, "integrations": { @@ -56,6 +64,11 @@ "href": "https://fastmcp.cloud", "icon": "cloud", "label": "FastMCP Cloud" + }, + { + "href": "https://discord.gg/uu8dJCgttd", + "icon": "discord", + "label": "Discord" } ], "primary": { @@ -64,15 +77,17 @@ } }, "navigation": { - "tabs": [ + "dropdowns": [ { + "dropdown": "Documentation", "groups": [ { "group": "Get Started", "pages": [ "getting-started/welcome", "getting-started/installation", - "getting-started/quickstart" + "getting-started/quickstart", + "updates" ] }, { @@ -240,18 +255,12 @@ "pages": [ "development/contributing", "development/tests", - "development/releases" + "development/releases", + "changelog" ] } ], - "tab": "Documentation" - }, - { - "pages": [ - "updates", - "changelog" - ], - "tab": "What's New" + "icon": "book" }, { "anchors": [ @@ -266,7 +275,6 @@ "group": "fastmcp.cli", "pages": [ "python-sdk/fastmcp-cli-__init__", - "python-sdk/fastmcp-cli-claude", "python-sdk/fastmcp-cli-cli", { "group": "install", @@ -347,8 +355,10 @@ "python-sdk/fastmcp-server-auth-providers-github", "python-sdk/fastmcp-server-auth-providers-google", "python-sdk/fastmcp-server-auth-providers-in_memory", + "python-sdk/fastmcp-server-auth-providers-introspection", "python-sdk/fastmcp-server-auth-providers-jwt", "python-sdk/fastmcp-server-auth-providers-scalekit", + "python-sdk/fastmcp-server-auth-providers-supabase", "python-sdk/fastmcp-server-auth-providers-workos" ] }, @@ -429,15 +439,16 @@ ] }, "python-sdk/fastmcp-utilities-openapi", - "python-sdk/fastmcp-utilities-storage", "python-sdk/fastmcp-utilities-tests", - "python-sdk/fastmcp-utilities-types" + "python-sdk/fastmcp-utilities-types", + "python-sdk/fastmcp-utilities-ui" ] } ] } ], - "tab": "SDK Reference" + "dropdown": "SDK Reference", + "icon": "code" } ] }, @@ -454,7 +465,7 @@ "search": { "prompt": "Search the docs..." }, - "theme": "mint", + "theme": "almond", "thumbnails": { "appearance": "light", "background": "/assets/brand/thumbnail-background.png" diff --git a/docs/getting-started/welcome.mdx b/docs/getting-started/welcome.mdx index bc03f0fb1..738f5d3ca 100644 --- a/docs/getting-started/welcome.mdx +++ b/docs/getting-started/welcome.mdx @@ -3,6 +3,7 @@ title: "Welcome to FastMCP 2.0!" sidebarTitle: "Welcome!" description: The fast, Pythonic way to build MCP servers and clients. icon: hand-wave +mode: wide --- - -```python -get_claude_config_path() -> Path | None -``` - - -Get the Claude config directory based on platform. - - -### `update_claude_config` - -```python -update_claude_config(file_spec: str, server_name: str) -> bool -``` - - -Add or update a FastMCP server in Claude's configuration. - -**Args:** -- `file_spec`: Path to the server file, optionally with \:object suffix -- `server_name`: Name for the server in Claude's config -- `with_editable`: Optional list of directories to install in editable mode -- `with_packages`: Optional list of additional packages to install -- `env_vars`: Optional dictionary of environment variables. These are merged with -any existing variables, with new values taking precedence. - -**Raises:** -- `RuntimeError`: If Claude Desktop's config directory is not found, indicating -Claude Desktop may not be installed or properly set up. - diff --git a/docs/python-sdk/fastmcp-cli-cli.mdx b/docs/python-sdk/fastmcp-cli-cli.mdx index a6f4ee13b..c913c5cf7 100644 --- a/docs/python-sdk/fastmcp-cli-cli.mdx +++ b/docs/python-sdk/fastmcp-cli-cli.mdx @@ -10,7 +10,7 @@ FastMCP CLI tools using Cyclopts. ## Functions -### `with_argv` +### `with_argv` ```python with_argv(args: list[str] | None) @@ -27,7 +27,7 @@ Args are provided without the script name, so we preserve sys.argv[0] and replace the rest. -### `version` +### `version` ```python version() @@ -37,7 +37,7 @@ version() Display version information and platform details. -### `dev` +### `dev` ```python dev(server_spec: str | None = None) -> None @@ -50,7 +50,7 @@ Run an MCP server with the MCP Inspector for development. - `server_spec`: Python file to run, optionally with \:object suffix, or None to auto-detect fastmcp.json -### `run` +### `run` ```python run(server_spec: str | None = None, *server_args: str) -> None @@ -74,7 +74,7 @@ fastmcp run server.py -- --config config.json --debug - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect -### `inspect` +### `inspect` ```python inspect(server_spec: str | None = None) -> None @@ -105,7 +105,7 @@ fastmcp inspect # auto-detect fastmcp.json - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json -### `prepare` +### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None diff --git a/docs/python-sdk/fastmcp-cli-run.mdx b/docs/python-sdk/fastmcp-cli-run.mdx index f9a5f874d..2909c19a8 100644 --- a/docs/python-sdk/fastmcp-cli-run.mdx +++ b/docs/python-sdk/fastmcp-cli-run.mdx @@ -84,8 +84,18 @@ Run a MCP server or connect to a remote one. - `skip_source`: Whether to skip source preparation step -### `run_v1_server` +### `run_v1_server_async` ```python -run_v1_server(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None +run_v1_server_async(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None ``` + + +Run a FastMCP 1.x server using async methods. + +**Args:** +- `server`: FastMCP 1.x server instance +- `host`: Host to bind to +- `port`: Port to bind to +- `transport`: Transport protocol to use + diff --git a/docs/python-sdk/fastmcp-client-auth-oauth.mdx b/docs/python-sdk/fastmcp-client-auth-oauth.mdx index cc3dd7cf5..7574d7a29 100644 --- a/docs/python-sdk/fastmcp-client-auth-oauth.mdx +++ b/docs/python-sdk/fastmcp-client-auth-oauth.mdx @@ -7,13 +7,7 @@ sidebarTitle: oauth ## Functions -### `default_cache_dir` - -```python -default_cache_dir() -> Path -``` - -### `check_if_auth_required` +### `check_if_auth_required` ```python check_if_auth_required(mcp_url: str, httpx_kwargs: dict[str, Any] | None = None) -> bool @@ -28,97 +22,47 @@ Check if the MCP endpoint requires authentication by making a test request. ## Classes -### `ClientNotFoundError` +### `ClientNotFoundError` Raised when OAuth client credentials are not found on the server. -### `StoredToken` - - -Token storage format with absolute expiry time. - - -### `FileTokenStorage` - - -File-based token storage implementation for OAuth credentials and tokens. -Implements the mcp.client.auth.TokenStorage protocol. - -Each instance is tied to a specific server URL for proper token isolation. -Uses JSONFileStorage internally for consistent file handling. - +### `TokenStorageAdapter` **Methods:** -#### `get_base_url` - -```python -get_base_url(url: str) -> str -``` - -Extract the base URL (scheme + host) from a URL. - - -#### `get_tokens` - -```python -get_tokens(self) -> OAuthToken | None -``` - -Load tokens from file storage. - - -#### `set_tokens` - -```python -set_tokens(self, tokens: OAuthToken) -> None -``` - -Save tokens to file storage. - - -#### `get_client_info` - -```python -get_client_info(self) -> OAuthClientInformationFull | None -``` - -Load client information from file storage. - - -#### `set_client_info` - -```python -set_client_info(self, client_info: OAuthClientInformationFull) -> None -``` - -Save client information to file storage. - - -#### `clear` +#### `clear` ```python clear(self) -> None ``` -Clear all cached data for this server. - -Note: This is a synchronous method for backward compatibility. -Uses direct file operations instead of async storage methods. - - -#### `clear_all` +#### `get_tokens` ```python -clear_all(cls, cache_dir: Path | None = None) -> None +get_tokens(self) -> OAuthToken | None ``` -Clear all cached data for all servers. +#### `set_tokens` +```python +set_tokens(self, tokens: OAuthToken) -> None +``` -### `OAuth` +#### `get_client_info` + +```python +get_client_info(self) -> OAuthClientInformationFull | None +``` + +#### `set_client_info` + +```python +set_client_info(self, client_info: OAuthClientInformationFull) -> None +``` + +### `OAuth` OAuth client provider for MCP servers with browser-based authentication. @@ -129,7 +73,7 @@ a browser for user authorization and running a local callback server. **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -138,7 +82,7 @@ redirect_handler(self, authorization_url: str) -> None Open browser for authorization, with pre-flight check for invalid client. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] @@ -147,7 +91,7 @@ callback_handler(self) -> tuple[str, str | None] Handle OAuth callback and return (auth_code, state). -#### `async_auth_flow` +#### `async_auth_flow` ```python async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response] diff --git a/docs/python-sdk/fastmcp-client-logging.mdx b/docs/python-sdk/fastmcp-client-logging.mdx index 8d9176ea8..b2f8d250d 100644 --- a/docs/python-sdk/fastmcp-client-logging.mdx +++ b/docs/python-sdk/fastmcp-client-logging.mdx @@ -7,7 +7,7 @@ sidebarTitle: logging ## Functions -### `default_log_handler` +### `default_log_handler` ```python default_log_handler(message: LogMessage) -> None @@ -17,7 +17,7 @@ default_log_handler(message: LogMessage) -> None Default handler that properly routes server log messages to appropriate log levels. -### `create_log_callback` +### `create_log_callback` ```python create_log_callback(handler: LogHandler | None = None) -> LoggingFnT diff --git a/docs/python-sdk/fastmcp-client-oauth_callback.mdx b/docs/python-sdk/fastmcp-client-oauth_callback.mdx index beddcd64f..87e667b76 100644 --- a/docs/python-sdk/fastmcp-client-oauth_callback.mdx +++ b/docs/python-sdk/fastmcp-client-oauth_callback.mdx @@ -15,7 +15,7 @@ and display styled responses to users. ## Functions -### `create_callback_html` +### `create_callback_html` ```python create_callback_html(message: str, is_success: bool = True, title: str = 'FastMCP OAuth', server_url: str | None = None) -> str @@ -25,7 +25,7 @@ create_callback_html(message: str, is_success: bool = True, title: str = 'FastMC Create a styled HTML response for OAuth callbacks. -### `create_oauth_callback_server` +### `create_oauth_callback_server` ```python create_oauth_callback_server(port: int, callback_path: str = '/callback', server_url: str | None = None, response_future: asyncio.Future | None = None) -> Server @@ -46,17 +46,17 @@ Create an OAuth callback server. ## Classes -### `CallbackResponse` +### `CallbackResponse` **Methods:** -#### `from_dict` +#### `from_dict` ```python from_dict(cls, data: dict[str, str]) -> CallbackResponse ``` -#### `to_dict` +#### `to_dict` ```python to_dict(self) -> dict[str, str] diff --git a/docs/python-sdk/fastmcp-client-transports.mdx b/docs/python-sdk/fastmcp-client-transports.mdx index af31fb50e..f403c6a35 100644 --- a/docs/python-sdk/fastmcp-client-transports.mdx +++ b/docs/python-sdk/fastmcp-client-transports.mdx @@ -7,7 +7,7 @@ sidebarTitle: transports ## Functions -### `infer_transport` +### `infer_transport` ```python infer_transport(transport: ClientTransport | FastMCP | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str) -> ClientTransport @@ -155,67 +155,67 @@ transports like Python, Node, Uvx, etc. **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -#### `connect` +#### `connect` ```python connect(self, **session_kwargs: Unpack[SessionKwargs]) -> ClientSession | None ``` -#### `disconnect` +#### `disconnect` ```python disconnect(self) ``` -#### `close` +#### `close` ```python close(self) ``` -### `PythonStdioTransport` +### `PythonStdioTransport` Transport for running Python scripts. -### `FastMCPStdioTransport` +### `FastMCPStdioTransport` Transport for running FastMCP servers using the FastMCP CLI. -### `NodeStdioTransport` +### `NodeStdioTransport` Transport for running Node.js scripts. -### `UvStdioTransport` +### `UvStdioTransport` Transport for running commands via the uv tool. -### `UvxStdioTransport` +### `UvxStdioTransport` Transport for running commands via the uvx tool. -### `NpxStdioTransport` +### `NpxStdioTransport` Transport for running commands via the npx tool. -### `FastMCPTransport` +### `FastMCPTransport` In-memory transport for FastMCP servers. @@ -228,13 +228,13 @@ tests or scenarios where client and server run in the same runtime. **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -### `MCPConfigTransport` +### `MCPConfigTransport` Transport for connecting to one or more MCP servers defined in an MCPConfig. @@ -287,13 +287,13 @@ async with client: **Methods:** -#### `connect_session` +#### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` -#### `close` +#### `close` ```python close(self) diff --git a/docs/python-sdk/fastmcp-prompts-prompt.mdx b/docs/python-sdk/fastmcp-prompts-prompt.mdx index 075ff97e1..c1d5d0a4d 100644 --- a/docs/python-sdk/fastmcp-prompts-prompt.mdx +++ b/docs/python-sdk/fastmcp-prompts-prompt.mdx @@ -10,7 +10,7 @@ Base classes for FastMCP prompts. ## Functions -### `Message` +### `Message` ```python Message(content: str | ContentBlock, role: Role | None = None, **kwargs: Any) -> PromptMessage @@ -22,13 +22,13 @@ A user-friendly constructor for PromptMessage. ## Classes -### `PromptArgument` +### `PromptArgument` An argument that can be passed to a prompt. -### `Prompt` +### `Prompt` A prompt template that can be rendered with parameters. @@ -36,19 +36,19 @@ A prompt template that can be rendered with parameters. **Methods:** -#### `enable` +#### `enable` ```python enable(self) -> None ``` -#### `disable` +#### `disable` ```python disable(self) -> None ``` -#### `to_mcp_prompt` +#### `to_mcp_prompt` ```python to_mcp_prompt(self, **overrides: Any) -> MCPPrompt @@ -57,7 +57,7 @@ to_mcp_prompt(self, **overrides: Any) -> MCPPrompt Convert the prompt to an MCP prompt. -#### `from_function` +#### `from_function` ```python from_function(fn: Callable[..., PromptResult | Awaitable[PromptResult]], name: str | None = None, title: str | None = None, description: str | None = None, tags: set[str] | None = None, enabled: bool | None = None, meta: dict[str, Any] | None = None) -> FunctionPrompt @@ -72,7 +72,7 @@ The function can return: - A sequence of any of the above -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> list[PromptMessage] @@ -80,8 +80,11 @@ render(self, arguments: dict[str, Any] | None = None) -> list[PromptMessage] Render the prompt with arguments. +This method is not implemented in the base Prompt class and must be +implemented by subclasses. -### `FunctionPrompt` + +### `FunctionPrompt` A prompt that is a function. @@ -89,7 +92,7 @@ A prompt that is a function. **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., PromptResult | Awaitable[PromptResult]], name: str | None = None, title: str | None = None, description: str | None = None, tags: set[str] | None = None, enabled: bool | None = None, meta: dict[str, Any] | None = None) -> FunctionPrompt @@ -104,7 +107,7 @@ The function can return: - A sequence of any of the above -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> list[PromptMessage] diff --git a/docs/python-sdk/fastmcp-prompts-prompt_manager.mdx b/docs/python-sdk/fastmcp-prompts-prompt_manager.mdx index d0fcfae56..30f85c18d 100644 --- a/docs/python-sdk/fastmcp-prompts-prompt_manager.mdx +++ b/docs/python-sdk/fastmcp-prompts-prompt_manager.mdx @@ -7,7 +7,7 @@ sidebarTitle: prompt_manager ## Classes -### `PromptManager` +### `PromptManager` Manages FastMCP prompts. @@ -15,16 +15,7 @@ Manages FastMCP prompts. **Methods:** -#### `mount` - -```python -mount(self, server: MountedServer) -> None -``` - -Adds a mounted server as a source for prompts. - - -#### `has_prompt` +#### `has_prompt` ```python has_prompt(self, key: str) -> bool @@ -33,7 +24,7 @@ has_prompt(self, key: str) -> bool Check if a prompt exists. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, key: str) -> Prompt @@ -42,25 +33,16 @@ get_prompt(self, key: str) -> Prompt Get prompt by key. -#### `get_prompts` +#### `get_prompts` ```python get_prompts(self) -> dict[str, Prompt] ``` -Gets the complete, unfiltered inventory of all prompts. +Gets the complete, unfiltered inventory of local prompts. -#### `list_prompts` - -```python -list_prompts(self) -> list[Prompt] -``` - -Lists all prompts, applying protocol filtering. - - -#### `add_prompt_from_fn` +#### `add_prompt_from_fn` ```python add_prompt_from_fn(self, fn: Callable[..., PromptResult | Awaitable[PromptResult]], name: str | None = None, description: str | None = None, tags: set[str] | None = None) -> FunctionPrompt @@ -69,7 +51,7 @@ add_prompt_from_fn(self, fn: Callable[..., PromptResult | Awaitable[PromptResult Create a prompt from a function. -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt) -> Prompt @@ -78,7 +60,7 @@ add_prompt(self, prompt: Prompt) -> Prompt Add a prompt to the manager. -#### `render_prompt` +#### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult diff --git a/docs/python-sdk/fastmcp-resources-resource.mdx b/docs/python-sdk/fastmcp-resources-resource.mdx index 6acd375af..94a0cf2f7 100644 --- a/docs/python-sdk/fastmcp-resources-resource.mdx +++ b/docs/python-sdk/fastmcp-resources-resource.mdx @@ -10,7 +10,7 @@ Base classes and interfaces for FastMCP resources. ## Classes -### `Resource` +### `Resource` Base class for all resources. @@ -18,25 +18,25 @@ Base class for all resources. **Methods:** -#### `enable` +#### `enable` ```python enable(self) -> None ``` -#### `disable` +#### `disable` ```python disable(self) -> None ``` -#### `from_function` +#### `from_function` ```python from_function(fn: Callable[..., Any], uri: str | AnyUrl, name: str | None = None, title: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, enabled: bool | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None) -> FunctionResource ``` -#### `set_default_mime_type` +#### `set_default_mime_type` ```python set_default_mime_type(cls, mime_type: str | None) -> str @@ -45,7 +45,7 @@ set_default_mime_type(cls, mime_type: str | None) -> str Set default MIME type if not provided. -#### `set_default_name` +#### `set_default_name` ```python set_default_name(self) -> Self @@ -54,7 +54,7 @@ set_default_name(self) -> Self Set default name from URI if not provided. -#### `read` +#### `read` ```python read(self) -> str | bytes @@ -62,8 +62,11 @@ read(self) -> str | bytes Read the resource content. +This method is not implemented in the base Resource class and must be +implemented by subclasses. -#### `to_mcp_resource` + +#### `to_mcp_resource` ```python to_mcp_resource(self, **overrides: Any) -> MCPResource @@ -72,7 +75,7 @@ to_mcp_resource(self, **overrides: Any) -> MCPResource Convert the resource to an MCPResource. -#### `key` +#### `key` ```python key(self) -> str @@ -84,7 +87,7 @@ keys having a certain value, as the same tool loaded from different hierarchies of servers may have different keys. -### `FunctionResource` +### `FunctionResource` A resource that defers data loading by wrapping a function. @@ -101,7 +104,7 @@ The function can return: **Methods:** -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl, name: str | None = None, title: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, enabled: bool | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None) -> FunctionResource @@ -110,7 +113,7 @@ from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl, name: str | None = Create a FunctionResource from a function. -#### `read` +#### `read` ```python read(self) -> str | bytes diff --git a/docs/python-sdk/fastmcp-resources-resource_manager.mdx b/docs/python-sdk/fastmcp-resources-resource_manager.mdx index 3a4c7c59c..c977132ba 100644 --- a/docs/python-sdk/fastmcp-resources-resource_manager.mdx +++ b/docs/python-sdk/fastmcp-resources-resource_manager.mdx @@ -10,7 +10,7 @@ Resource manager functionality. ## Classes -### `ResourceManager` +### `ResourceManager` Manages FastMCP resources. @@ -18,16 +18,7 @@ Manages FastMCP resources. **Methods:** -#### `mount` - -```python -mount(self, server: MountedServer) -> None -``` - -Adds a mounted server as a source for resources and templates. - - -#### `get_resources` +#### `get_resources` ```python get_resources(self) -> dict[str, Resource] @@ -36,7 +27,7 @@ get_resources(self) -> dict[str, Resource] Get all registered resources, keyed by URI. -#### `get_resource_templates` +#### `get_resource_templates` ```python get_resource_templates(self) -> dict[str, ResourceTemplate] @@ -45,25 +36,7 @@ get_resource_templates(self) -> dict[str, ResourceTemplate] Get all registered templates, keyed by URI template. -#### `list_resources` - -```python -list_resources(self) -> list[Resource] -``` - -Lists all resources, applying protocol filtering. - - -#### `list_resource_templates` - -```python -list_resource_templates(self) -> list[ResourceTemplate] -``` - -Lists all templates, applying protocol filtering. - - -#### `add_resource_or_template_from_fn` +#### `add_resource_or_template_from_fn` ```python add_resource_or_template_from_fn(self, fn: Callable[..., Any], uri: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> Resource | ResourceTemplate @@ -84,7 +57,7 @@ Add a resource or template to the manager from a function. - returns the existing resource or template. -#### `add_resource_from_fn` +#### `add_resource_from_fn` ```python add_resource_from_fn(self, fn: Callable[..., Any], uri: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> Resource @@ -105,7 +78,7 @@ Add a resource to the manager from a function. - returns the existing resource. -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource) -> Resource @@ -119,7 +92,7 @@ will be used as the storage key. To overwrite it, call Resource.model_copy(key=new_key) before calling this method. -#### `add_template_from_fn` +#### `add_template_from_fn` ```python add_template_from_fn(self, fn: Callable[..., Any], uri_template: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> ResourceTemplate @@ -128,7 +101,7 @@ add_template_from_fn(self, fn: Callable[..., Any], uri_template: str, name: str Create a template from a function. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -146,7 +119,7 @@ ResourceTemplate.model_copy(key=new_key) before calling this method. - returns the existing template. -#### `has_resource` +#### `has_resource` ```python has_resource(self, uri: AnyUrl | str) -> bool @@ -155,7 +128,7 @@ has_resource(self, uri: AnyUrl | str) -> bool Check if a resource exists. -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: AnyUrl | str) -> Resource @@ -170,7 +143,7 @@ Get resource by URI, checking concrete resources first, then templates. - `NotFoundError`: If no resource or template matching the URI is found. -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: AnyUrl | str) -> str | bytes diff --git a/docs/python-sdk/fastmcp-resources-template.mdx b/docs/python-sdk/fastmcp-resources-template.mdx index f67ac5e14..3ea550b0b 100644 --- a/docs/python-sdk/fastmcp-resources-template.mdx +++ b/docs/python-sdk/fastmcp-resources-template.mdx @@ -10,21 +10,48 @@ Resource template functionality. ## Functions -### `build_regex` +### `extract_query_params` + +```python +extract_query_params(uri_template: str) -> set[str] +``` + + +Extract query parameter names from RFC 6570 {?param1,param2} syntax. + + +### `build_regex` ```python build_regex(template: str) -> re.Pattern ``` -### `match_uri_template` + +Build regex pattern for URI template, handling RFC 6570 syntax. + +Supports: +- {var} - simple path parameter +- {var*} - wildcard path parameter (captures multiple segments) +- {?var1,var2} - query parameters (ignored in path matching) + + +### `match_uri_template` ```python match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None ``` + +Match URI against template and extract both path and query parameters. + +Supports RFC 6570 URI templates: +- Path params: {var}, {var*} +- Query params: {?var1,var2} + + ## Classes -### `ResourceTemplate` +### `ResourceTemplate` A template for dynamically creating resources. @@ -32,25 +59,25 @@ A template for dynamically creating resources. **Methods:** -#### `enable` +#### `enable` ```python enable(self) -> None ``` -#### `disable` +#### `disable` ```python disable(self) -> None ``` -#### `from_function` +#### `from_function` ```python from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, title: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, enabled: bool | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None) -> FunctionResourceTemplate ``` -#### `set_default_mime_type` +#### `set_default_mime_type` ```python set_default_mime_type(cls, mime_type: str | None) -> str @@ -59,7 +86,7 @@ set_default_mime_type(cls, mime_type: str | None) -> str Set default MIME type if not provided. -#### `matches` +#### `matches` ```python matches(self, uri: str) -> dict[str, Any] | None @@ -68,7 +95,7 @@ matches(self, uri: str) -> dict[str, Any] | None Check if URI matches template and extract parameters. -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes @@ -77,7 +104,7 @@ read(self, arguments: dict[str, Any]) -> str | bytes Read the resource content. -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource @@ -86,7 +113,7 @@ create_resource(self, uri: str, params: dict[str, Any]) -> Resource Create a resource from the template with the given parameters. -#### `to_mcp_template` +#### `to_mcp_template` ```python to_mcp_template(self, **overrides: Any) -> MCPResourceTemplate @@ -95,7 +122,7 @@ to_mcp_template(self, **overrides: Any) -> MCPResourceTemplate Convert the resource template to an MCPResourceTemplate. -#### `from_mcp_template` +#### `from_mcp_template` ```python from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate @@ -104,7 +131,7 @@ from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object. -#### `key` +#### `key` ```python key(self) -> str @@ -116,7 +143,7 @@ keys having a certain value, as the same tool loaded from different hierarchies of servers may have different keys. -### `FunctionResourceTemplate` +### `FunctionResourceTemplate` A template for dynamically creating resources. @@ -124,7 +151,7 @@ A template for dynamically creating resources. **Methods:** -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes @@ -133,7 +160,7 @@ read(self, arguments: dict[str, Any]) -> str | bytes Read the resource content. -#### `from_function` +#### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, title: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None, enabled: bool | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None) -> FunctionResourceTemplate diff --git a/docs/python-sdk/fastmcp-server-auth-auth.mdx b/docs/python-sdk/fastmcp-server-auth-auth.mdx index d95e116d2..ae7f2f543 100644 --- a/docs/python-sdk/fastmcp-server-auth-auth.mdx +++ b/docs/python-sdk/fastmcp-server-auth-auth.mdx @@ -7,13 +7,13 @@ sidebarTitle: auth ## Classes -### `AccessToken` +### `AccessToken` AccessToken that includes all JWT claims. -### `AuthProvider` +### `AuthProvider` Base class for all FastMCP authentication providers. @@ -26,7 +26,7 @@ custom authentication routes. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -43,10 +43,10 @@ All auth providers must implement token verification. - AccessToken object if valid, None if invalid or expired -#### `get_routes` +#### `get_routes` ```python -get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] +get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get the routes for this authentication provider. @@ -59,13 +59,14 @@ Each provider is responsible for creating whatever routes it needs: **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -- `mcp_endpoint`: The MCP endpoint handler to protect with auth +This is used to advertise the resource URL in metadata, but the +provider does not create the actual MCP endpoint route. **Returns:** -- List of routes for this provider, including protected MCP endpoints if provided +- List of routes for this provider (excluding the MCP endpoint itself) -#### `get_middleware` +#### `get_middleware` ```python get_middleware(self) -> list @@ -77,7 +78,7 @@ Get HTTP application-level middleware for this auth provider. - List of Starlette Middleware instances to apply to the HTTP app -### `TokenVerifier` +### `TokenVerifier` Base class for token verifiers (Resource Servers). @@ -88,7 +89,7 @@ Token verifiers typically don't provide authentication routes by default. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -97,7 +98,7 @@ verify_token(self, token: str) -> AccessToken | None Verify a bearer token and return access info if valid. -### `RemoteAuthProvider` +### `RemoteAuthProvider` Authentication provider for resource servers that verify tokens from known authorization servers. @@ -114,7 +115,7 @@ the authorization servers that issue valid tokens. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -123,18 +124,18 @@ verify_token(self, token: str) -> AccessToken | None Verify token using the configured token verifier. -#### `get_routes` +#### `get_routes` ```python -get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] +get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth routes for this provider. -Creates protected resource metadata routes and optionally wraps MCP endpoints with auth. +Creates protected resource metadata routes. -### `OAuthProvider` +### `OAuthProvider` OAuth Authorization Server provider. @@ -145,7 +146,7 @@ authorization flows, token issuance, and token verification. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -163,10 +164,10 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -#### `get_routes` +#### `get_routes` ```python -get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] +get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth authorization server routes and optional protected resource routes. @@ -174,7 +175,6 @@ Get OAuth authorization server routes and optional protected resource routes. This method creates the full set of OAuth routes including: - Standard OAuth authorization server routes (/.well-known/oauth-authorization-server, /authorize, /token, etc.) - Optional protected resource routes -- Protected MCP endpoints if provided **Returns:** - List of OAuth routes diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx index df2a262ba..9b363a46d 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy.mdx @@ -24,9 +24,39 @@ This implementation is based on the OAuth 2.1 specification and is designed for production use with enterprise identity providers. +## Functions + +### `create_consent_html` + +```python +create_consent_html(client_id: str, redirect_uri: str, scopes: list[str], txn_id: str, csrf_token: str, client_name: str | None = None, title: str = 'Authorization Consent') -> str +``` + + +Create a styled HTML consent page for OAuth authorization requests. + + ## Classes -### `ProxyDCRClient` +### `OAuthTransaction` + + +OAuth transaction state for consent flow. + +Stored server-side to track active authorization flows with client context. +Includes CSRF tokens for consent protection per MCP security best practices. + + +### `ClientCode` + + +Client authorization code with PKCE and upstream tokens. + +Stored server-side after upstream IdP callback. Contains the upstream +tokens bound to the client's PKCE challenge for secure token exchange. + + +### `ProxyDCRClient` Client for DCR proxy with configurable redirect URI validation. @@ -56,7 +86,7 @@ arise from accepting arbitrary redirect URIs. **Methods:** -#### `validate_redirect_uri` +#### `validate_redirect_uri` ```python validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl @@ -70,7 +100,39 @@ This is essential for cached token scenarios where the client may reconnect with a different port. -### `OAuthProxy` +### `TokenHandler` + + +TokenHandler that returns OAuth 2.1 compliant error responses. + +The MCP SDK always returns HTTP 400 for all client authentication issues. +However, OAuth 2.1 Section 5.3 and the MCP specification require that +invalid or expired tokens MUST receive a HTTP 401 response. + +This handler extends the base MCP SDK TokenHandler to transform client +authentication failures into OAuth 2.1 compliant responses: +- Changes 'unauthorized_client' to 'invalid_client' error code +- Returns HTTP 401 status code instead of 400 for client auth failures + +Per OAuth 2.1 Section 5.3: "The authorization server MAY return an HTTP 401 +(Unauthorized) status code to indicate which HTTP authentication schemes +are supported." + +Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response." + + +**Methods:** + +#### `response` + +```python +response(self, obj: TokenSuccessResponse | TokenErrorResponse) +``` + +Override response method to provide OAuth 2.1 compliant error handling. + + +### `OAuthProxy` OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. @@ -151,7 +213,6 @@ OAuth Flow Implementation State Management --------------- The proxy maintains minimal but crucial state: -- _clients: DCR registrations (all use ProxyDCRClient for flexibility) - _oauth_transactions: Active authorization flows with client context - _client_codes: Authorization codes with PKCE challenges and upstream tokens - _access_tokens, _refresh_tokens: Token storage for revocation @@ -181,7 +242,7 @@ Handles provider-specific requirements: **Methods:** -#### `get_client` +#### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None @@ -193,7 +254,7 @@ provided to the DCR client during registration, not the upstream client ID. For unregistered clients, returns None (which will raise an error in the SDK). -#### `register_client` +#### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None @@ -207,22 +268,21 @@ redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str ``` -Start OAuth transaction and redirect to upstream IdP. +Start OAuth transaction and route through consent interstitial. -This implements the DCR-compliant proxy pattern: -1. Store transaction with client details and PKCE challenge -2. Generate proxy's own PKCE parameters if forwarding is enabled -3. Use transaction ID as state for IdP -4. Redirect to IdP with our fixed callback URL and proxy's PKCE +Flow: +1. Store transaction with client details and PKCE (if forwarding) +2. Return local /consent URL; browser visits consent first +3. Consent handler redirects to upstream IdP if approved/already approved -#### `load_authorization_code` +#### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None @@ -234,7 +294,7 @@ Look up our client code and return authorization code object with PKCE challenge for validation. -#### `exchange_authorization_code` +#### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken @@ -246,7 +306,7 @@ For the DCR-compliant proxy flow, we return the IdP tokens that were obtained during the IdP callback exchange. PKCE validation is handled by the MCP framework. -#### `load_refresh_token` +#### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None @@ -255,7 +315,7 @@ load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) Load refresh token from local storage. -#### `exchange_refresh_token` +#### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken @@ -264,7 +324,7 @@ exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: Exchange refresh token for new access token using authlib. -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -276,7 +336,7 @@ Delegates to the JWT verifier which handles signature validation, expiration checking, and claims validation using the upstream JWKS. -#### `revoke_token` +#### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None @@ -288,10 +348,10 @@ Removes tokens from local storage and attempts to revoke them with the upstream server if a revocation endpoint is configured. -#### `get_routes` +#### `get_routes` ```python -get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] +get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth routes with custom proxy token handler. @@ -301,5 +361,5 @@ with our proxy handler that forwards requests to the upstream OAuth server. **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -- `mcp_endpoint`: The MCP endpoint handler to protect with auth +This is used to advertise the resource URL in metadata. diff --git a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx index f20b2dcdf..ed0df1307 100644 --- a/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx @@ -52,7 +52,7 @@ that is OIDC compliant. **Methods:** -#### `get_oidc_configuration` +#### `get_oidc_configuration` ```python get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration @@ -66,7 +66,7 @@ Gets the OIDC configuration for the specified configuration URL. - `timeout_seconds`: HTTP request timeout in seconds -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx b/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx index 344f29b64..fc58544d5 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx @@ -31,13 +31,13 @@ Example: ## Classes -### `Auth0ProviderSettings` +### `Auth0ProviderSettings` Settings for Auth0 OIDC provider. -### `Auth0Provider` +### `Auth0Provider` An Auth0 provider implementation for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx index 290951a5a..cf8063a9d 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx @@ -31,13 +31,13 @@ Example: ## Classes -### `AWSCognitoProviderSettings` +### `AWSCognitoProviderSettings` Settings for AWS Cognito OAuth provider. -### `AWSCognitoTokenVerifier` +### `AWSCognitoTokenVerifier` Token verifier that filters claims to Cognito-specific subset. @@ -45,7 +45,7 @@ Token verifier that filters claims to Cognito-specific subset. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -54,7 +54,7 @@ verify_token(self, token: str) -> AccessToken | None Verify token and filter claims to Cognito-specific subset. -### `AWSCognitoProvider` +### `AWSCognitoProvider` Complete AWS Cognito OAuth provider for FastMCP. @@ -72,7 +72,7 @@ Features: **Methods:** -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index 463e5f731..d44054dc7 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -14,33 +14,13 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. ## Classes -### `AzureProviderSettings` +### `AzureProviderSettings` Settings for Azure OAuth provider. -### `AzureTokenVerifier` - - -Token verifier for Azure OAuth tokens. - -Azure tokens are JWTs, but we verify them by calling the Microsoft Graph API -to get user information and validate the token. - - -**Methods:** - -#### `verify_token` - -```python -verify_token(self, token: str) -> AccessToken | None -``` - -Verify Azure OAuth token by calling Microsoft Graph API. - - -### `AzureProvider` +### `AzureProvider` Azure (Microsoft Entra) OAuth provider for FastMCP. @@ -50,14 +30,37 @@ OAuth Proxy pattern. It supports both organizational accounts and personal Microsoft accounts depending on the tenant configuration. Features: -- Transparent OAuth proxy to Azure/Microsoft identity platform -- Automatic token validation via Microsoft Graph API -- User information extraction -- Support for different tenant configurations (common, organizations, consumers) +- OAuth proxy to Azure/Microsoft identity platform +- JWT validation using tenant issuer and JWKS +- Supports tenant configurations: specific tenant ID, "organizations", or "consumers" -Setup Requirements: -1. Register an application in Azure Portal (portal.azure.com) -2. Configure redirect URI as: http://localhost:8000/auth/callback -3. Note your Application (client) ID and create a client secret -4. Optionally note your Directory (tenant) ID for single-tenant apps +Setup: +1. Create an App registration in Azure Portal +2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path) +3. Add an Application ID URI. Either use the default (api://{client_id}) or set a custom one. +4. Add a custom scope. +5. Create a client secret. +6. Get Application (client) ID, Directory (tenant) ID, and client secret + + +**Methods:** + +#### `authorize` + +```python +authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str +``` + +Start OAuth transaction and redirect to Azure AD. + +Override parent's authorize method to filter out the 'resource' parameter +which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use +scopes to determine the resource/audience instead of a separate parameter. + +**Args:** +- `client`: OAuth client information +- `params`: Authorization parameters from the client + +**Returns:** +- Authorization URL to redirect the user to Azure AD diff --git a/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx index 2064a25b1..9d14c4964 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-descope.mdx @@ -15,9 +15,9 @@ for seamless MCP client authentication. ## Classes -### `DescopeProviderSettings` +### `DescopeProviderSettings` -### `DescopeProvider` +### `DescopeProvider` Descope metadata provider for DCR (Dynamic Client Registration). @@ -45,10 +45,10 @@ https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps# **Methods:** -#### `get_routes` +#### `get_routes` ```python -get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] +get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth routes including Descope authorization server metadata forwarding. @@ -58,5 +58,5 @@ metadata endpoint that forwards Descope's OAuth metadata to clients. **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -- `mcp_endpoint`: The MCP endpoint handler to protect with auth +This is used to advertise the resource URL in metadata. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx index e72034495..0a93964b3 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-github.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-github.mdx @@ -29,13 +29,13 @@ Example: ## Classes -### `GitHubProviderSettings` +### `GitHubProviderSettings` Settings for GitHub OAuth provider. -### `GitHubTokenVerifier` +### `GitHubTokenVerifier` Token verifier for GitHub OAuth tokens. @@ -46,7 +46,7 @@ by calling GitHub's API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None Verify GitHub OAuth token by calling GitHub API. -### `GitHubProvider` +### `GitHubProvider` Complete GitHub OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx index 20d3a63e9..90ae29dab 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-google.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-google.mdx @@ -29,13 +29,13 @@ Example: ## Classes -### `GoogleProviderSettings` +### `GoogleProviderSettings` Settings for Google OAuth provider. -### `GoogleTokenVerifier` +### `GoogleTokenVerifier` Token verifier for Google OAuth tokens. @@ -46,7 +46,7 @@ by calling Google's tokeninfo API to check if they're valid and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -55,7 +55,7 @@ verify_token(self, token: str) -> AccessToken | None Verify Google OAuth token by calling Google's tokeninfo API. -### `GoogleProvider` +### `GoogleProvider` Complete Google OAuth provider for FastMCP. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-introspection.mdx b/docs/python-sdk/fastmcp-server-auth-providers-introspection.mdx new file mode 100644 index 000000000..1d7eeda22 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-providers-introspection.mdx @@ -0,0 +1,77 @@ +--- +title: introspection +sidebarTitle: introspection +--- + +# `fastmcp.server.auth.providers.introspection` + + +OAuth 2.0 Token Introspection (RFC 7662) provider for FastMCP. + +This module provides token verification for opaque tokens using the OAuth 2.0 +Token Introspection protocol defined in RFC 7662. It allows FastMCP servers to +validate tokens issued by authorization servers that don't use JWT format. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier + + # Verify opaque tokens via RFC 7662 introspection + verifier = IntrospectionTokenVerifier( + introspection_url="https://auth.example.com/oauth/introspect", + client_id="your-client-id", + client_secret="your-client-secret", + required_scopes=["read", "write"] + ) + + mcp = FastMCP("My Protected Server", auth=verifier) + ``` + + +## Classes + +### `IntrospectionTokenVerifierSettings` + + +Settings for OAuth 2.0 Token Introspection verification. + + +### `IntrospectionTokenVerifier` + + +OAuth 2.0 Token Introspection verifier (RFC 7662). + +This verifier validates opaque tokens by calling an OAuth 2.0 token introspection +endpoint. Unlike JWT verification which is stateless, token introspection requires +a network call to the authorization server for each token validation. + +The verifier authenticates to the introspection endpoint using HTTP Basic Auth +with the provided client_id and client_secret, as specified in RFC 7662. + +Use this when: +- Your authorization server issues opaque (non-JWT) tokens +- You need to validate tokens from Auth0, Okta, Keycloak, or other OAuth servers +- Your tokens require real-time revocation checking +- Your authorization server supports RFC 7662 introspection + + +**Methods:** + +#### `verify_token` + +```python +verify_token(self, token: str) -> AccessToken | None +``` + +Verify a bearer token using OAuth 2.0 Token Introspection (RFC 7662). + +This method makes a POST request to the introspection endpoint with the token, +authenticated using HTTP Basic Auth with the client credentials. + +**Args:** +- `token`: The opaque token string to validate + +**Returns:** +- AccessToken object if valid and active, None if invalid, inactive, or expired + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx index e46059402..345ec7b95 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx @@ -10,19 +10,19 @@ TokenVerifier implementations for FastMCP. ## Classes -### `JWKData` +### `JWKData` JSON Web Key data structure. -### `JWKSData` +### `JWKSData` JSON Web Key Set data structure. -### `RSAKeyPair` +### `RSAKeyPair` RSA key pair for JWT testing. @@ -30,7 +30,7 @@ RSA key pair for JWT testing. **Methods:** -#### `generate` +#### `generate` ```python generate(cls) -> RSAKeyPair @@ -42,7 +42,7 @@ Generate an RSA key pair for testing. - Generated key pair -#### `create_token` +#### `create_token` ```python create_token(self, subject: str = 'fastmcp-user', issuer: str = 'https://fastmcp.example.com', audience: str | list[str] | None = None, scopes: list[str] | None = None, expires_in_seconds: int = 3600, additional_claims: dict[str, Any] | None = None, kid: str | None = None) -> str @@ -60,13 +60,13 @@ Generate a test JWT token for testing purposes. - `kid`: Key ID to include in header -### `JWTVerifierSettings` +### `JWTVerifierSettings` Settings for JWT token verification. -### `JWTVerifier` +### `JWTVerifier` JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. @@ -88,7 +88,7 @@ Use this when: **Methods:** -#### `load_access_token` +#### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None @@ -103,7 +103,7 @@ Validates the provided JWT bearer token. - AccessToken object if valid, None if invalid or expired -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -121,7 +121,7 @@ to our existing load_access_token method. - AccessToken object if valid, None if invalid or expired -### `StaticTokenVerifier` +### `StaticTokenVerifier` Simple static token verifier for testing and development. @@ -142,7 +142,7 @@ WARNING: Never use this in production - tokens are stored in plain text! **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None diff --git a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx index f1b9a5df0..7dd465a29 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx @@ -15,9 +15,9 @@ authentication for seamless MCP client authentication. ## Classes -### `ScalekitProviderSettings` +### `ScalekitProviderSettings` -### `ScalekitProvider` +### `ScalekitProvider` Scalekit resource server provider for OAuth 2.1 authentication. @@ -47,10 +47,10 @@ https://docs.scalekit.com/mcp/overview/ **Methods:** -#### `get_routes` +#### `get_routes` ```python -get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] +get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth routes including Scalekit authorization server metadata forwarding. @@ -60,5 +60,5 @@ metadata endpoint that forwards Scalekit's OAuth metadata to clients. **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -- `mcp_endpoint`: The MCP endpoint handler to protect with auth +This is used to advertise the resource URL in metadata. diff --git a/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx b/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx new file mode 100644 index 000000000..603672d13 --- /dev/null +++ b/docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx @@ -0,0 +1,62 @@ +--- +title: supabase +sidebarTitle: supabase +--- + +# `fastmcp.server.auth.providers.supabase` + + +Supabase authentication provider for FastMCP. + +This module provides SupabaseProvider - a complete authentication solution that integrates +with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR) +for seamless MCP client authentication. + + +## Classes + +### `SupabaseProviderSettings` + +### `SupabaseProvider` + + +Supabase metadata provider for DCR (Dynamic Client Registration). + +This provider implements Supabase Auth integration using metadata forwarding. +This approach allows Supabase to handle the OAuth flow directly while FastMCP acts +as a resource server, verifying JWTs issued by Supabase Auth. + +IMPORTANT SETUP REQUIREMENTS: + +1. Supabase Project Setup: + - Create a Supabase project at https://supabase.com + - Note your project URL (e.g., "https://abc123.supabase.co") + - For projects created after May 1st, 2025, asymmetric RS256 keys are used by default + - For older projects, consider migrating to asymmetric keys for better security + +2. JWT Verification: + - FastMCP verifies JWTs using the JWKS endpoint at {project_url}/auth/v1/.well-known/jwks.json + - JWTs are issued by {project_url}/auth/v1 + - Tokens are cached for up to 10 minutes by Supabase's edge servers + +For detailed setup instructions, see: +https://supabase.com/docs/guides/auth/jwts + + +**Methods:** + +#### `get_routes` + +```python +get_routes(self, mcp_path: str | None = None) -> list[Route] +``` + +Get OAuth routes including Supabase authorization server metadata forwarding. + +This returns the standard protected resource routes plus an authorization server +metadata endpoint that forwards Supabase's OAuth metadata to clients. + +**Args:** +- `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") +This is used to advertise the resource URL in metadata. + diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx index a4ac73e02..e38942ee1 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx @@ -18,13 +18,13 @@ Choose based on your WorkOS setup and authentication requirements. ## Classes -### `WorkOSProviderSettings` +### `WorkOSProviderSettings` Settings for WorkOS OAuth provider. -### `WorkOSTokenVerifier` +### `WorkOSTokenVerifier` Token verifier for WorkOS OAuth tokens. @@ -35,7 +35,7 @@ the /oauth2/userinfo endpoint to check validity and get user info. **Methods:** -#### `verify_token` +#### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None @@ -44,7 +44,7 @@ verify_token(self, token: str) -> AccessToken | None Verify WorkOS OAuth token by calling userinfo endpoint. -### `WorkOSProvider` +### `WorkOSProvider` Complete WorkOS OAuth provider for FastMCP. @@ -65,9 +65,9 @@ Setup Requirements: 4. Note your Client ID and Client Secret -### `AuthKitProviderSettings` +### `AuthKitProviderSettings` -### `AuthKitProvider` +### `AuthKitProvider` AuthKit metadata provider for DCR (Dynamic Client Registration). @@ -93,10 +93,10 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** -#### `get_routes` +#### `get_routes` ```python -get_routes(self, mcp_path: str | None = None, mcp_endpoint: Any | None = None) -> list[Route] +get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth routes including AuthKit authorization server metadata forwarding. @@ -106,5 +106,5 @@ metadata endpoint that forwards AuthKit's OAuth metadata to clients. **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") -- `mcp_endpoint`: The MCP endpoint handler to protect with auth +This is used to advertise the resource URL in metadata. diff --git a/docs/python-sdk/fastmcp-server-context.mdx b/docs/python-sdk/fastmcp-server-context.mdx index f828d513f..13b6e18ca 100644 --- a/docs/python-sdk/fastmcp-server-context.mdx +++ b/docs/python-sdk/fastmcp-server-context.mdx @@ -7,7 +7,7 @@ sidebarTitle: context ## Functions -### `set_context` +### `set_context` ```python set_context(context: Context) -> Generator[Context, None, None] @@ -15,7 +15,7 @@ set_context(context: Context) -> Generator[Context, None, None] ## Classes -### `LogData` +### `LogData` Data object for passing log arguments to client-side handlers. @@ -24,7 +24,7 @@ This provides an interface to match the Python standard library logging, for compatibility with structured logging. -### `Context` +### `Context` Context object providing access to MCP capabilities. @@ -72,7 +72,7 @@ The context is optional - tools that don't need it can omit the parameter. **Methods:** -#### `fastmcp` +#### `fastmcp` ```python fastmcp(self) -> FastMCP @@ -81,7 +81,7 @@ fastmcp(self) -> FastMCP Get the FastMCP instance. -#### `request_context` +#### `request_context` ```python request_context(self) -> RequestContext[ServerSession, Any, Request] @@ -92,7 +92,7 @@ Access to the underlying request context. If called outside of a request context, this will raise a ValueError. -#### `report_progress` +#### `report_progress` ```python report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None @@ -105,7 +105,7 @@ Report progress for the current operation. - `total`: Optional total value e.g. 100 -#### `read_resource` +#### `read_resource` ```python read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents] @@ -120,7 +120,7 @@ Read a resource by URI. - The resource content as either text or bytes -#### `log` +#### `log` ```python log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None @@ -128,6 +128,8 @@ log(self, message: str, level: LoggingLevel | None = None, logger_name: str | No Send a log message to the client. +Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. + **Args:** - `message`: Log message - `level`: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical", @@ -136,7 +138,7 @@ Send a log message to the client. - `extra`: Optional mapping for additional arguments -#### `client_id` +#### `client_id` ```python client_id(self) -> str | None @@ -145,7 +147,7 @@ client_id(self) -> str | None Get the client ID if available. -#### `request_id` +#### `request_id` ```python request_id(self) -> str @@ -154,7 +156,7 @@ request_id(self) -> str Get the unique ID for this request. -#### `session_id` +#### `session_id` ```python session_id(self) -> str @@ -171,7 +173,7 @@ the same client session. - for other transports. -#### `session` +#### `session` ```python session(self) -> ServerSession @@ -180,43 +182,51 @@ session(self) -> ServerSession Access to the underlying session for advanced usage. -#### `debug` +#### `debug` ```python debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None ``` -Send a debug log message. +Send a `DEBUG`-level message to the connected MCP Client. + +Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `info` +#### `info` ```python info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None ``` -Send an info log message. +Send a `INFO`-level message to the connected MCP Client. + +Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `warning` +#### `warning` ```python warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None ``` -Send a warning log message. +Send a `WARNING`-level message to the connected MCP Client. + +Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `error` +#### `error` ```python error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None ``` -Send an error log message. +Send a `ERROR`-level message to the connected MCP Client. + +Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. -#### `list_roots` +#### `list_roots` ```python list_roots(self) -> list[Root] @@ -225,7 +235,7 @@ list_roots(self) -> list[Root] List the roots available to the server, as indicated by the client. -#### `send_tool_list_changed` +#### `send_tool_list_changed` ```python send_tool_list_changed(self) -> None @@ -234,7 +244,7 @@ send_tool_list_changed(self) -> None Send a tool list changed notification to the client. -#### `send_resource_list_changed` +#### `send_resource_list_changed` ```python send_resource_list_changed(self) -> None @@ -243,7 +253,7 @@ send_resource_list_changed(self) -> None Send a resource list changed notification to the client. -#### `send_prompt_list_changed` +#### `send_prompt_list_changed` ```python send_prompt_list_changed(self) -> None @@ -252,7 +262,7 @@ send_prompt_list_changed(self) -> None Send a prompt list changed notification to the client. -#### `sample` +#### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage], system_prompt: str | None = None, include_context: IncludeContext | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> TextContent | ImageContent | AudioContent @@ -265,25 +275,25 @@ completion from the client. The client must be appropriately configured, or the request will error. -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` -#### `elicit` +#### `elicit` ```python elicit(self, message: str, response_type: type[T] | list[str] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation @@ -312,7 +322,7 @@ type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. -#### `get_http_request` +#### `get_http_request` ```python get_http_request(self) -> Request @@ -321,7 +331,7 @@ get_http_request(self) -> Request Get the active starlette request. -#### `set_state` +#### `set_state` ```python set_state(self, key: str, value: Any) -> None @@ -330,7 +340,7 @@ set_state(self, key: str, value: Any) -> None Set a value in the context state. -#### `get_state` +#### `get_state` ```python get_state(self, key: str) -> Any diff --git a/docs/python-sdk/fastmcp-server-http.mdx b/docs/python-sdk/fastmcp-server-http.mdx index 0a7ee46ca..6ded1069f 100644 --- a/docs/python-sdk/fastmcp-server-http.mdx +++ b/docs/python-sdk/fastmcp-server-http.mdx @@ -54,7 +54,7 @@ Returns: A Starlette application with RequestContextMiddleware -### `create_streamable_http_app` +### `create_streamable_http_app` ```python create_streamable_http_app(server: FastMCP[LifespanResultT], streamable_http_path: str, event_store: EventStore | None = None, auth: AuthProvider | None = None, json_response: bool = False, stateless_http: bool = False, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan diff --git a/docs/python-sdk/fastmcp-server-low_level.mdx b/docs/python-sdk/fastmcp-server-low_level.mdx index 2f7197358..fd3dcaeec 100644 --- a/docs/python-sdk/fastmcp-server-low_level.mdx +++ b/docs/python-sdk/fastmcp-server-low_level.mdx @@ -7,12 +7,47 @@ sidebarTitle: low_level ## Classes -### `LowLevelServer` +### `MiddlewareServerSession` + + +ServerSession that routes initialization requests through FastMCP middleware. + **Methods:** -#### `create_initialization_options` +#### `fastmcp` + +```python +fastmcp(self) -> FastMCP +``` + +Get the FastMCP instance. + + +### `LowLevelServer` + +**Methods:** + +#### `fastmcp` + +```python +fastmcp(self) -> FastMCP +``` + +Get the FastMCP instance. + + +#### `create_initialization_options` ```python create_initialization_options(self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, **kwargs: Any) -> InitializationOptions ``` + +#### `run` + +```python +run(self, read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], write_stream: MemoryObjectSendStream[SessionMessage], initialization_options: InitializationOptions, raise_exceptions: bool = False, stateless: bool = False) +``` + +Overrides the run method to use the MiddlewareServerSession. + diff --git a/docs/python-sdk/fastmcp-server-middleware-logging.mdx b/docs/python-sdk/fastmcp-server-middleware-logging.mdx index fd6ee041c..292d55583 100644 --- a/docs/python-sdk/fastmcp-server-middleware-logging.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-logging.mdx @@ -10,7 +10,7 @@ Comprehensive logging middleware for FastMCP servers. ## Functions -### `default_serializer` +### `default_serializer` ```python default_serializer(data: Any) -> str @@ -22,7 +22,7 @@ The default serializer for Payloads in the logging middleware. ## Classes -### `BaseLoggingMiddleware` +### `BaseLoggingMiddleware` Base class for logging middleware. @@ -30,16 +30,16 @@ Base class for logging middleware. **Methods:** -#### `on_message` +#### `on_message` ```python on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any ``` -Log all messages. +Log messages for configured methods. -### `LoggingMiddleware` +### `LoggingMiddleware` Middleware that provides comprehensive request and response logging. @@ -48,7 +48,7 @@ Logs all MCP messages with configurable detail levels. Useful for debugging, monitoring, and understanding server usage patterns. -### `StructuredLoggingMiddleware` +### `StructuredLoggingMiddleware` Middleware that provides structured JSON logging for better log analysis. diff --git a/docs/python-sdk/fastmcp-server-middleware-middleware.mdx b/docs/python-sdk/fastmcp-server-middleware-middleware.mdx index a530af4b1..428c7e260 100644 --- a/docs/python-sdk/fastmcp-server-middleware-middleware.mdx +++ b/docs/python-sdk/fastmcp-server-middleware-middleware.mdx @@ -45,62 +45,68 @@ Base class for FastMCP middleware with dispatching hooks. **Methods:** -#### `on_message` +#### `on_message` ```python on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any ``` -#### `on_request` +#### `on_request` ```python -on_request(self, context: MiddlewareContext[mt.Request], call_next: CallNext[mt.Request, Any]) -> Any +on_request(self, context: MiddlewareContext[mt.Request[Any, Any]], call_next: CallNext[mt.Request[Any, Any], Any]) -> Any ``` -#### `on_notification` +#### `on_notification` ```python -on_notification(self, context: MiddlewareContext[mt.Notification], call_next: CallNext[mt.Notification, Any]) -> Any +on_notification(self, context: MiddlewareContext[mt.Notification[Any, Any]], call_next: CallNext[mt.Notification[Any, Any], Any]) -> Any ``` -#### `on_call_tool` +#### `on_initialize` + +```python +on_initialize(self, context: MiddlewareContext[mt.InitializeRequestParams], call_next: CallNext[mt.InitializeRequestParams, None]) -> None +``` + +#### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult ``` -#### `on_read_resource` +#### `on_read_resource` ```python -on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams], call_next: CallNext[mt.ReadResourceRequestParams, list[ReadResourceContents]]) -> list[ReadResourceContents] +on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams], call_next: CallNext[mt.ReadResourceRequestParams, Sequence[ReadResourceContents]]) -> Sequence[ReadResourceContents] ``` -#### `on_get_prompt` +#### `on_get_prompt` ```python on_get_prompt(self, context: MiddlewareContext[mt.GetPromptRequestParams], call_next: CallNext[mt.GetPromptRequestParams, mt.GetPromptResult]) -> mt.GetPromptResult ``` -#### `on_list_tools` +#### `on_list_tools` ```python -on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, list[Tool]]) -> list[Tool] +on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] ``` -#### `on_list_resources` +#### `on_list_resources` ```python -on_list_resources(self, context: MiddlewareContext[mt.ListResourcesRequest], call_next: CallNext[mt.ListResourcesRequest, list[Resource]]) -> list[Resource] +on_list_resources(self, context: MiddlewareContext[mt.ListResourcesRequest], call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]]) -> Sequence[Resource] ``` -#### `on_list_resource_templates` +#### `on_list_resource_templates` ```python -on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, list[ResourceTemplate]]) -> list[ResourceTemplate] +on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]]) -> Sequence[ResourceTemplate] ``` -#### `on_list_prompts` +#### `on_list_prompts` ```python -on_list_prompts(self, context: MiddlewareContext[mt.ListPromptsRequest], call_next: CallNext[mt.ListPromptsRequest, list[Prompt]]) -> list[Prompt] +on_list_prompts(self, context: MiddlewareContext[mt.ListPromptsRequest], call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]]) -> Sequence[Prompt] ``` diff --git a/docs/python-sdk/fastmcp-server-server.mdx b/docs/python-sdk/fastmcp-server-server.mdx index 20b58f460..0c25c2d8d 100644 --- a/docs/python-sdk/fastmcp-server-server.mdx +++ b/docs/python-sdk/fastmcp-server-server.mdx @@ -10,7 +10,7 @@ FastMCP - A more ergonomic interface for MCP servers. ## Functions -### `default_lifespan` +### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] @@ -23,10 +23,10 @@ Default lifespan context manager that does nothing. - `server`: The server instance this lifespan is managing **Returns:** -- An empty context object +- An empty dictionary as the lifespan result. -### `add_resource_prefix` +### `add_resource_prefix` ```python add_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str @@ -64,7 +64,7 @@ add_resource_prefix("resource:///absolute/path", "prefix") - `ValueError`: If the URI doesn't match the expected protocol\://path format -### `remove_resource_prefix` +### `remove_resource_prefix` ```python remove_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> str @@ -103,7 +103,7 @@ remove_resource_prefix("resource://prefix//absolute/path", "prefix") - `ValueError`: If the URI doesn't match the expected protocol\://path format -### `has_resource_prefix` +### `has_resource_prefix` ```python has_resource_prefix(uri: str, prefix: str, prefix_format: Literal['protocol', 'path'] | None = None) -> bool @@ -143,41 +143,41 @@ False ## Classes -### `FastMCP` +### `FastMCP` **Methods:** -#### `settings` +#### `settings` ```python settings(self) -> Settings ``` -#### `name` +#### `name` ```python name(self) -> str ``` -#### `instructions` +#### `instructions` ```python instructions(self) -> str | None ``` -#### `instructions` +#### `instructions` ```python instructions(self, value: str | None) -> None ``` -#### `version` +#### `version` ```python version(self) -> str | None ``` -#### `run_async` +#### `run_async` ```python run_async(self, transport: Transport | None = None, show_banner: bool = True, **transport_kwargs: Any) -> None @@ -189,7 +189,7 @@ Run the FastMCP server asynchronously. - `transport`: Transport protocol to use ("stdio", "sse", or "streamable-http") -#### `run` +#### `run` ```python run(self, transport: Transport | None = None, show_banner: bool = True, **transport_kwargs: Any) -> None @@ -201,52 +201,52 @@ Run the FastMCP server. Note this is a synchronous function. - `transport`: Transport protocol to use ("stdio", "sse", or "streamable-http") -#### `add_middleware` +#### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` -#### `get_tools` +#### `get_tools` ```python get_tools(self) -> dict[str, Tool] ``` -Get all registered tools, indexed by registered key. +Get all tools (unfiltered), including mounted servers, indexed by key. -#### `get_tool` +#### `get_tool` ```python get_tool(self, key: str) -> Tool ``` -#### `get_resources` +#### `get_resources` ```python get_resources(self) -> dict[str, Resource] ``` -Get all registered resources, indexed by registered key. +Get all resources (unfiltered), including mounted servers, indexed by key. -#### `get_resource` +#### `get_resource` ```python get_resource(self, key: str) -> Resource ``` -#### `get_resource_templates` +#### `get_resource_templates` ```python get_resource_templates(self) -> dict[str, ResourceTemplate] ``` -Get all registered resource templates, indexed by registered key. +Get all resource templates (unfiltered), including mounted servers, indexed by key. -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, key: str) -> ResourceTemplate @@ -255,22 +255,22 @@ get_resource_template(self, key: str) -> ResourceTemplate Get a registered resource template by key. -#### `get_prompts` +#### `get_prompts` ```python get_prompts(self) -> dict[str, Prompt] ``` -List all available prompts. +Get all prompts (unfiltered), including mounted servers, indexed by key. -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, key: str) -> Prompt ``` -#### `custom_route` +#### `custom_route` ```python custom_route(self, path: str, methods: list[str], name: str | None = None, include_in_schema: bool = True) -> Callable[[Callable[[Request], Awaitable[Response]]], Callable[[Request], Awaitable[Response]]] @@ -291,7 +291,7 @@ Starlette's reverse URL lookup feature) - `include_in_schema`: Whether to include in OpenAPI schema, defaults to True -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool) -> Tool @@ -309,7 +309,7 @@ with the Context type annotation. See the @tool decorator for examples. - The tool instance that was added to the server. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, name: str) -> None @@ -324,7 +324,7 @@ Remove a tool from the server. - `NotFoundError`: If the tool is not found -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -333,7 +333,7 @@ add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfi Add a tool transformation. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, tool_name: str) -> None @@ -342,19 +342,19 @@ remove_tool_transformation(self, tool_name: str) -> None Remove a tool transformation. -#### `tool` +#### `tool` ```python tool(self, name_or_fn: AnyFunction) -> FunctionTool ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionTool] ``` -#### `tool` +#### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool @@ -410,7 +410,7 @@ server.tool(my_function, name="custom_name") ``` -#### `add_resource` +#### `add_resource` ```python add_resource(self, resource: Resource) -> Resource @@ -425,7 +425,7 @@ Add a resource to the server. - The resource instance that was added to the server. -#### `add_template` +#### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate @@ -440,7 +440,7 @@ Add a resource template to the server. - The template instance that was added to the server. -#### `add_resource_fn` +#### `add_resource_fn` ```python add_resource_fn(self, fn: AnyFunction, uri: str, name: str | None = None, description: str | None = None, mime_type: str | None = None, tags: set[str] | None = None) -> None @@ -460,7 +460,7 @@ has parameters, it will be registered as a template resource. - `tags`: Optional set of tags for categorizing the resource -#### `resource` +#### `resource` ```python resource(self, uri: str) -> Callable[[AnyFunction], Resource | ResourceTemplate] @@ -520,7 +520,7 @@ async def get_weather(city: str) -> str: ``` -#### `add_prompt` +#### `add_prompt` ```python add_prompt(self, prompt: Prompt) -> Prompt @@ -535,19 +535,19 @@ Add a prompt to the server. - The prompt instance that was added to the server. -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: AnyFunction) -> FunctionPrompt ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[AnyFunction], FunctionPrompt] ``` -#### `prompt` +#### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt @@ -625,7 +625,7 @@ Decorator to register a prompt. ``` -#### `run_stdio_async` +#### `run_stdio_async` ```python run_stdio_async(self, show_banner: bool = True, log_level: str | None = None) -> None @@ -638,10 +638,10 @@ Run the server using stdio transport. - `log_level`: Log level for the server -#### `run_http_async` +#### `run_http_async` ```python -run_http_async(self, show_banner: bool = True, transport: Literal['http', 'streamable-http', 'sse'] = 'http', host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None, middleware: list[ASGIMiddleware] | None = None, stateless_http: bool | None = None) -> None +run_http_async(self, show_banner: bool = True, transport: Literal['http', 'streamable-http', 'sse'] = 'http', host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None) -> None ``` Run the server using HTTP transport. @@ -654,10 +654,11 @@ Run the server using HTTP transport. - `path`: Path for the endpoint (defaults to settings.streamable_http_path or settings.sse_path) - `uvicorn_config`: Additional configuration for the Uvicorn server - `middleware`: A list of middleware to apply to the app +- `json_response`: Whether to use JSON response format (defaults to settings.json_response) - `stateless_http`: Whether to use stateless HTTP (defaults to settings.stateless_http) -#### `run_sse_async` +#### `run_sse_async` ```python run_sse_async(self, host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None) -> None @@ -666,7 +667,7 @@ run_sse_async(self, host: str | None = None, port: int | None = None, log_level: Run the server using SSE transport. -#### `sse_app` +#### `sse_app` ```python sse_app(self, path: str | None = None, message_path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan @@ -680,7 +681,7 @@ Create a Starlette app for the SSE server. - `middleware`: A list of middleware to apply to the app -#### `streamable_http_app` +#### `streamable_http_app` ```python streamable_http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None) -> StarletteWithLifespan @@ -693,7 +694,7 @@ Create a Starlette app for the StreamableHTTP server. - `middleware`: A list of middleware to apply to the app -#### `http_app` +#### `http_app` ```python http_app(self, path: str | None = None, middleware: list[ASGIMiddleware] | None = None, json_response: bool | None = None, stateless_http: bool | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http') -> StarletteWithLifespan @@ -710,13 +711,13 @@ Create a Starlette app using the specified HTTP transport. - A Starlette application configured with the specified transport -#### `run_streamable_http_async` +#### `run_streamable_http_async` ```python run_streamable_http_async(self, host: str | None = None, port: int | None = None, log_level: str | None = None, path: str | None = None, uvicorn_config: dict[str, Any] | None = None) -> None ``` -#### `mount` +#### `mount` ```python mount(self, server: FastMCP[LifespanResultT], prefix: str | None = None, as_proxy: bool | None = None) -> None @@ -770,7 +771,7 @@ automatically determined based on whether the server has a custom lifespan - `prompt_separator`: Deprecated. Separator character for prompt names. -#### `import_server` +#### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None, tool_separator: str | None = None, resource_separator: str | None = None, prompt_separator: str | None = None) -> None @@ -811,7 +812,7 @@ applied using the protocol\://prefix/path format - `prompt_separator`: Deprecated. Separator for prompt names. -#### `from_openapi` +#### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew @@ -820,7 +821,7 @@ from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, route Create a FastMCP server from an OpenAPI specification. -#### `from_fastapi` +#### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | list[RouteMapNew] | None = None, route_map_fn: OpenAPIRouteMapFn | OpenAPIRouteMapFnNew | None = None, mcp_component_fn: OpenAPIComponentFn | OpenAPIComponentFnNew | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> FastMCPOpenAPI | FastMCPOpenAPINew @@ -829,7 +830,7 @@ from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] Create a FastMCP server from a FastAPI application. -#### `as_proxy` +#### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy @@ -843,7 +844,7 @@ instance or any value accepted as the `transport` argument of `fastmcp.client.Client` constructor. -#### `from_client` +#### `from_client` ```python from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPProxy @@ -852,10 +853,10 @@ from_client(cls, client: Client[ClientTransportT], **settings: Any) -> FastMCPPr Create a FastMCP proxy server from a FastMCP client. -#### `generate_name` +#### `generate_name` ```python generate_name(cls, name: str | None = None) -> str ``` -### `MountedServer` +### `MountedServer` diff --git a/docs/python-sdk/fastmcp-settings.mdx b/docs/python-sdk/fastmcp-settings.mdx index 6f14415ec..4a0bb8448 100644 --- a/docs/python-sdk/fastmcp-settings.mdx +++ b/docs/python-sdk/fastmcp-settings.mdx @@ -7,7 +7,7 @@ sidebarTitle: settings ## Classes -### `ExtendedEnvSettingsSource` +### `ExtendedEnvSettingsSource` A special EnvSettingsSource that allows for multiple env var prefixes to be used. @@ -17,17 +17,17 @@ Raises a deprecation warning if the old `FASTMCP_SERVER_` prefix is used. **Methods:** -#### `get_field_value` +#### `get_field_value` ```python get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool] ``` -### `ExtendedSettingsConfigDict` +### `ExtendedSettingsConfigDict` -### `ExperimentalSettings` +### `ExperimentalSettings` -### `Settings` +### `Settings` FastMCP settings. @@ -35,7 +35,7 @@ FastMCP settings. **Methods:** -#### `get_setting` +#### `get_setting` ```python get_setting(self, attr: str) -> Any @@ -45,7 +45,7 @@ Get a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `set_setting` +#### `set_setting` ```python set_setting(self, attr: str, value: Any) -> None @@ -55,13 +55,13 @@ Set a setting. If the setting contains one or more `__`, it will be treated as a nested setting. -#### `settings_customise_sources` +#### `settings_customise_sources` ```python settings_customise_sources(cls, settings_cls: type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource) -> tuple[PydanticBaseSettingsSource, ...] ``` -#### `settings` +#### `settings` ```python settings(self) -> Self @@ -71,13 +71,13 @@ This property is for backwards compatibility with FastMCP < 2.8.0, which accessed fastmcp.settings.settings -#### `normalize_log_level` +#### `normalize_log_level` ```python normalize_log_level(cls, v) ``` -#### `server_auth_class` +#### `server_auth_class` ```python server_auth_class(self) -> AuthProvider | None diff --git a/docs/python-sdk/fastmcp-tools-tool_manager.mdx b/docs/python-sdk/fastmcp-tools-tool_manager.mdx index 15cbee1db..bf07cb0fd 100644 --- a/docs/python-sdk/fastmcp-tools-tool_manager.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_manager.mdx @@ -7,7 +7,7 @@ sidebarTitle: tool_manager ## Classes -### `ToolManager` +### `ToolManager` Manages FastMCP tools. @@ -15,16 +15,7 @@ Manages FastMCP tools. **Methods:** -#### `mount` - -```python -mount(self, server: MountedServer) -> None -``` - -Adds a mounted server as a source for tools. - - -#### `has_tool` +#### `has_tool` ```python has_tool(self, key: str) -> bool @@ -33,7 +24,7 @@ has_tool(self, key: str) -> bool Check if a tool exists. -#### `get_tool` +#### `get_tool` ```python get_tool(self, key: str) -> Tool @@ -42,25 +33,16 @@ get_tool(self, key: str) -> Tool Get tool by key. -#### `get_tools` +#### `get_tools` ```python get_tools(self) -> dict[str, Tool] ``` -Gets the complete, unfiltered inventory of all tools. +Gets the complete, unfiltered inventory of local tools. -#### `list_tools` - -```python -list_tools(self) -> list[Tool] -``` - -Lists all tools, applying protocol filtering. - - -#### `add_tool_from_fn` +#### `add_tool_from_fn` ```python add_tool_from_fn(self, fn: Callable[..., Any], name: str | None = None, description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, serializer: Callable[[Any], str] | None = None, exclude_args: list[str] | None = None) -> Tool @@ -69,7 +51,7 @@ add_tool_from_fn(self, fn: Callable[..., Any], name: str | None = None, descript Add a tool to the server. -#### `add_tool` +#### `add_tool` ```python add_tool(self, tool: Tool) -> Tool @@ -78,7 +60,7 @@ add_tool(self, tool: Tool) -> Tool Register a tool with the server. -#### `add_tool_transformation` +#### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None @@ -87,7 +69,7 @@ add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfi Add a tool transformation. -#### `get_tool_transformation` +#### `get_tool_transformation` ```python get_tool_transformation(self, tool_name: str) -> ToolTransformConfig | None @@ -96,7 +78,7 @@ get_tool_transformation(self, tool_name: str) -> ToolTransformConfig | None Get a tool transformation. -#### `remove_tool_transformation` +#### `remove_tool_transformation` ```python remove_tool_transformation(self, tool_name: str) -> None @@ -105,7 +87,7 @@ remove_tool_transformation(self, tool_name: str) -> None Remove a tool transformation. -#### `remove_tool` +#### `remove_tool` ```python remove_tool(self, key: str) -> None @@ -120,7 +102,7 @@ Remove a tool from the server. - `NotFoundError`: If the tool is not found -#### `call_tool` +#### `call_tool` ```python call_tool(self, key: str, arguments: dict[str, Any]) -> ToolResult diff --git a/docs/python-sdk/fastmcp-tools-tool_transform.mdx b/docs/python-sdk/fastmcp-tools-tool_transform.mdx index ea8652fab..e3d856876 100644 --- a/docs/python-sdk/fastmcp-tools-tool_transform.mdx +++ b/docs/python-sdk/fastmcp-tools-tool_transform.mdx @@ -10,7 +10,7 @@ sidebarTitle: tool_transform ### `forward` ```python -forward(**kwargs) -> ToolResult +forward(**kwargs: Any) -> ToolResult ``` @@ -39,7 +39,7 @@ tool has args `a` and `b`, and an `transform_args` was provided that maps `x` to ### `forward_raw` ```python -forward_raw(**kwargs) -> ToolResult +forward_raw(**kwargs: Any) -> ToolResult ``` diff --git a/docs/python-sdk/fastmcp-utilities-inspect.mdx b/docs/python-sdk/fastmcp-utilities-inspect.mdx index b48e52a5c..80a66b749 100644 --- a/docs/python-sdk/fastmcp-utilities-inspect.mdx +++ b/docs/python-sdk/fastmcp-utilities-inspect.mdx @@ -26,7 +26,7 @@ Extract information from a FastMCP v2.x instance. - FastMCPInfo dataclass containing the extracted information -### `inspect_fastmcp_v1` +### `inspect_fastmcp_v1` ```python inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo @@ -42,7 +42,7 @@ Extract information from a FastMCP v1.x instance using a Client. - FastMCPInfo dataclass containing the extracted information -### `inspect_fastmcp` +### `inspect_fastmcp` ```python inspect_fastmcp(mcp: FastMCP[Any] | FastMCP1x) -> FastMCPInfo @@ -61,7 +61,7 @@ and uses the appropriate extraction method. - FastMCPInfo dataclass containing the extracted information -### `format_fastmcp_info` +### `format_fastmcp_info` ```python format_fastmcp_info(info: FastMCPInfo) -> bytes @@ -73,7 +73,7 @@ Format FastMCPInfo as FastMCP-specific JSON. This includes FastMCP-specific fields like tags, enabled, annotations, etc. -### `format_mcp_info` +### `format_mcp_info` ```python format_mcp_info(mcp: FastMCP[Any] | FastMCP1x) -> bytes @@ -86,7 +86,7 @@ Uses Client to get the standard MCP protocol format with camelCase fields. Includes version metadata at the top level. -### `format_info` +### `format_info` ```python format_info(mcp: FastMCP[Any] | FastMCP1x, format: InspectFormat | Literal['fastmcp', 'mcp'], info: FastMCPInfo | None = None) -> bytes @@ -136,7 +136,7 @@ Information about a resource template. Information extracted from a FastMCP instance. -### `InspectFormat` +### `InspectFormat` Output format for inspect command. diff --git a/docs/python-sdk/fastmcp-utilities-logging.mdx b/docs/python-sdk/fastmcp-utilities-logging.mdx index f139db07a..fe976dcad 100644 --- a/docs/python-sdk/fastmcp-utilities-logging.mdx +++ b/docs/python-sdk/fastmcp-utilities-logging.mdx @@ -10,7 +10,7 @@ Logging utilities for FastMCP. ## Functions -### `get_logger` +### `get_logger` ```python get_logger(name: str) -> logging.Logger @@ -26,7 +26,7 @@ Get a logger nested under FastMCP namespace. - a configured logger instance -### `configure_logging` +### `configure_logging` ```python configure_logging(level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | int = 'INFO', logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) -> None @@ -41,7 +41,7 @@ Configure logging for FastMCP. - `rich_kwargs`: the parameters to use for creating RichHandler -### `temporary_log_level` +### `temporary_log_level` ```python temporary_log_level(level: str | None, logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) diff --git a/docs/python-sdk/fastmcp-utilities-storage.mdx b/docs/python-sdk/fastmcp-utilities-storage.mdx deleted file mode 100644 index 0d0ecd322..000000000 --- a/docs/python-sdk/fastmcp-utilities-storage.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: storage -sidebarTitle: storage ---- - -# `fastmcp.utilities.storage` - - -Key-value storage utilities for persistent data management. - -## Classes - -### `KVStorage` - - -Protocol for key-value storage of JSON data. - - -**Methods:** - -#### `get` - -```python -get(self, key: str) -> dict[str, Any] | None -``` - -Get a JSON dict by key. - - -#### `set` - -```python -set(self, key: str, value: dict[str, Any]) -> None -``` - -Store a JSON dict by key. - - -#### `delete` - -```python -delete(self, key: str) -> None -``` - -Delete a value by key. - - -### `JSONFileStorage` - - -File-based key-value storage for JSON data with automatic metadata tracking. - -Each key-value pair is stored as a separate JSON file on disk. -Keys are sanitized to be filesystem-safe. - -The storage automatically wraps all data with metadata: -- timestamp: Timestamp when the entry was last written - -**Args:** -- `cache_dir`: Directory for storing JSON files - - -**Methods:** - -#### `get` - -```python -get(self, key: str) -> dict[str, Any] | None -``` - -Get a JSON dict from storage by key. - -**Args:** -- `key`: The key to retrieve - -**Returns:** -- The stored dict or None if not found - - -#### `set` - -```python -set(self, key: str, value: dict[str, Any]) -> None -``` - -Store a JSON dict with metadata. - -**Args:** -- `key`: The key to store under -- `value`: The dict to store - - -#### `delete` - -```python -delete(self, key: str) -> None -``` - -Delete a value from storage. - -**Args:** -- `key`: The key to delete - - -#### `cleanup_old_entries` - -```python -cleanup_old_entries(self, max_age_seconds: int = 30 * 24 * 60 * 60) -> int -``` - -Remove entries older than the specified age. - -Uses the timestamp field to determine age. - -**Args:** -- `max_age_seconds`: Maximum age in seconds (default 30 days) - -**Returns:** -- Number of entries removed - - -### `InMemoryStorage` - - -In-memory key-value storage for JSON data. - -Simple dict-based storage that doesn't persist across restarts. -Useful for testing or environments where file storage isn't available. - - -**Methods:** - -#### `get` - -```python -get(self, key: str) -> dict[str, Any] | None -``` - -Get a JSON dict from memory by key. - - -#### `set` - -```python -set(self, key: str, value: dict[str, Any]) -> None -``` - -Store a JSON dict in memory. - - -#### `delete` - -```python -delete(self, key: str) -> None -``` - -Delete a value from memory. - diff --git a/docs/python-sdk/fastmcp-utilities-tests.mdx b/docs/python-sdk/fastmcp-utilities-tests.mdx index 669755575..dbe05bde5 100644 --- a/docs/python-sdk/fastmcp-utilities-tests.mdx +++ b/docs/python-sdk/fastmcp-utilities-tests.mdx @@ -20,10 +20,10 @@ Temporarily override FastMCP setting values. - `**kwargs`: The settings to override, including nested settings. -### `run_server_in_process` +### `run_server_in_process` ```python -run_server_in_process(server_fn: Callable[..., None], *args, **kwargs) -> Generator[str, None, None] +run_server_in_process(server_fn: Callable[..., None], *args: Any, **kwargs: Any) -> Generator[str, None, None] ``` @@ -43,7 +43,7 @@ not pickleable, so we need a function that creates and runs one. - The server URL. -### `caplog_for_fastmcp` +### `caplog_for_fastmcp` ```python caplog_for_fastmcp(caplog) @@ -55,7 +55,7 @@ Context manager to capture logs from FastMCP loggers even when propagation is di ## Classes -### `HeadlessOAuth` +### `HeadlessOAuth` OAuth provider that bypasses browser interaction for testing. @@ -66,7 +66,7 @@ instead of opening a browser and running a callback server. Useful for automated **Methods:** -#### `redirect_handler` +#### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None @@ -75,7 +75,7 @@ redirect_handler(self, authorization_url: str) -> None Make HTTP request to authorization URL and store response for callback handler. -#### `callback_handler` +#### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] diff --git a/docs/python-sdk/fastmcp-utilities-types.mdx b/docs/python-sdk/fastmcp-utilities-types.mdx index 288191eb9..8b6cf44ca 100644 --- a/docs/python-sdk/fastmcp-utilities-types.mdx +++ b/docs/python-sdk/fastmcp-utilities-types.mdx @@ -133,7 +133,7 @@ to_audio_content(self, mime_type: str | None = None, annotations: Annotations | ### `File` -Helper class for returning audio from tools. +Helper class for returning file data from tools. **Methods:** diff --git a/docs/python-sdk/fastmcp-utilities-ui.mdx b/docs/python-sdk/fastmcp-utilities-ui.mdx new file mode 100644 index 000000000..77fd80184 --- /dev/null +++ b/docs/python-sdk/fastmcp-utilities-ui.mdx @@ -0,0 +1,131 @@ +--- +title: ui +sidebarTitle: ui +--- + +# `fastmcp.utilities.ui` + + + +Shared UI utilities for FastMCP HTML pages. + +This module provides reusable HTML/CSS components for OAuth callbacks, +consent pages, and other user-facing interfaces. + + +## Functions + +### `create_page` + +```python +create_page(content: str, title: str = 'FastMCP', additional_styles: str = '', csp_policy: str = "default-src 'none'; style-src 'unsafe-inline'; img-src https:; base-uri 'none'") -> str +``` + + +Create a complete HTML page with FastMCP styling. + +**Args:** +- `content`: HTML content to place inside the page +- `title`: Page title +- `additional_styles`: Extra CSS to include +- `csp_policy`: Content Security Policy header value + +**Returns:** +- Complete HTML page as string + + +### `create_logo` + +```python +create_logo() -> str +``` + + +Create FastMCP logo HTML. + + +### `create_status_message` + +```python +create_status_message(message: str, is_success: bool = True) -> str +``` + + +Create a status message with icon. + +**Args:** +- `message`: Status message text +- `is_success`: True for success (✓), False for error (✕) + +**Returns:** +- HTML for status message + + +### `create_info_box` + +```python +create_info_box(content: str, is_error: bool = False, centered: bool = False) -> str +``` + + +Create an info box. + +**Args:** +- `content`: HTML content for the info box +- `is_error`: True for error styling, False for normal +- `centered`: True to center the text, False for left-aligned + +**Returns:** +- HTML for info box + + +### `create_detail_box` + +```python +create_detail_box(rows: list[tuple[str, str]]) -> str +``` + + +Create a detail box with key-value pairs. + +**Args:** +- `rows`: List of (label, value) tuples + +**Returns:** +- HTML for detail box + + +### `create_button_group` + +```python +create_button_group(buttons: list[tuple[str, str, str]]) -> str +``` + + +Create a group of buttons. + +**Args:** +- `buttons`: List of (text, value, css_class) tuples + +**Returns:** +- HTML for button group + + +### `create_secure_html_response` + +```python +create_secure_html_response(html: str, status_code: int = 200) -> HTMLResponse +``` + + +Create an HTMLResponse with security headers. + +Adds X-Frame-Options: DENY to prevent clickjacking attacks per MCP security best practices. + +**Args:** +- `html`: HTML content to return +- `status_code`: HTTP status code + +**Returns:** +- HTMLResponse with security headers + diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index c06276e26..47ce45616 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -226,7 +226,8 @@ Available providers include `GitHubProvider`, `GoogleProvider`, and others. Thes The OAuth proxy requires a compatible `TokenVerifier` to validate tokens from your provider. Different providers use different token formats: - **JWT tokens** (Google, Azure): Use `JWTVerifier` with the provider's JWKS endpoint -- **Opaque tokens** (GitHub, Discord): Use provider-specific verifiers or implement custom validation +- **Opaque tokens with RFC 7662 introspection** (Auth0, Okta, WorkOS): Use `IntrospectionTokenVerifier` +- **Opaque tokens (provider-specific)** (GitHub, Discord): Use provider-specific verifiers like `GitHubTokenVerifier` See the [Token Verification guide](/servers/auth/token-verification) for detailed setup instructions for your provider. diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx index b01aef315..befb6ec22 100644 --- a/docs/servers/auth/remote-oauth.mdx +++ b/docs/servers/auth/remote-oauth.mdx @@ -86,7 +86,12 @@ FastMCP provides `RemoteAuthProvider` to handle the complexities of remote OAuth `RemoteAuthProvider` works by composing a [`TokenVerifier`](/servers/auth/token-verification) with authorization server information. A `TokenVerifier` is another FastMCP authentication class that focuses solely on token validation - signature verification, expiration checking, and claim extraction. The `RemoteAuthProvider` takes that token validation capability and adds the OAuth discovery endpoints that enable MCP clients to automatically find and authenticate with your identity provider. -This composition pattern means you can use any token validation strategy (JWT verification, introspection endpoints, custom validation logic) while maintaining consistent OAuth discovery behavior. The separation allows you to change token validation approaches without affecting the client discovery experience. +This composition pattern means you can use any token validation strategy while maintaining consistent OAuth discovery behavior: +- **JWT tokens**: Use `JWTVerifier` for self-contained tokens +- **Opaque tokens**: Use `IntrospectionTokenVerifier` for RFC 7662 introspection +- **Custom validation**: Implement your own `TokenVerifier` subclass + +The separation allows you to change token validation approaches without affecting the client discovery experience. The class automatically generates the required OAuth metadata endpoints using the MCP SDK's standardized route creation functions. This ensures compatibility with MCP clients while reducing the implementation complexity for server developers. diff --git a/docs/servers/auth/token-verification.mdx b/docs/servers/auth/token-verification.mdx index d2523151c..637ff77a2 100644 --- a/docs/servers/auth/token-verification.mdx +++ b/docs/servers/auth/token-verification.mdx @@ -42,23 +42,22 @@ Token validation must address several security requirements: signature verificat The challenge in MCP environments is that clients need to obtain valid tokens before making requests, but the MCP protocol doesn't provide built-in discovery mechanisms for token endpoints. Clients must obtain tokens through separate channels or prior configuration. -## FastMCP Token Verification + +## TokenVerifier Class FastMCP provides the `TokenVerifier` class to handle token validation complexity while remaining flexible about token sources and validation strategies. -### TokenVerifier Design - `TokenVerifier` focuses exclusively on token validation without providing OAuth discovery metadata. This makes it ideal for internal systems where clients already know how to obtain tokens, or for microservices that trust tokens from known issuers. The class validates token signatures, checks expiration timestamps, and extracts authorization information from token claims. It supports various token formats and validation strategies while maintaining a consistent interface for authorization decisions. You can subclass `TokenVerifier` to implement custom validation logic for specialized token formats or validation requirements. The base class handles common patterns while allowing extension for unique use cases. -### JWT Token Verification +## JWT Token Verification JSON Web Tokens (JWTs) represent the most common token format for modern applications. FastMCP's `JWTVerifier` validates JWTs using industry-standard cryptographic techniques and claim validation. -#### JWKS Endpoint Integration +### JWKS Endpoint Integration JWKS endpoint integration provides the most flexible approach for production systems. The verifier automatically fetches public keys from a JSON Web Key Set endpoint, enabling automatic key rotation without server configuration changes. @@ -80,7 +79,7 @@ This configuration creates a server that validates JWTs issued by `auth.yourcomp The `issuer` parameter ensures tokens come from your trusted authentication system, while `audience` validation prevents tokens intended for other services from being accepted by your MCP server. -#### Symmetric Key Verification (HMAC) +### Symmetric Key Verification (HMAC) Symmetric key verification uses a shared secret for both signing and validation, making it ideal for internal microservices and trusted environments where the same secret can be securely distributed to both token issuers and validators. @@ -119,7 +118,7 @@ The parameter is named `public_key` for backwards compatibility, but when using - Consider using asymmetric keys (RSA/ECDSA) for external-facing APIs -#### Static Public Key Verification +### Static Public Key Verification Static public key verification works when you have a fixed RSA or ECDSA signing key and don't need automatic key rotation. This approach is primarily useful for development environments or controlled deployments where JWKS endpoints aren't available. @@ -142,12 +141,44 @@ mcp = FastMCP(name="Protected API", auth=verifier) ``` This configuration validates tokens using a specific RSA or ECDSA public key. The key must correspond to the private key used by your token issuer. While less flexible than JWKS endpoints, this approach can be useful in development environments or when testing with fixed keys. +## Opaque Token Verification -### Development and Testing +Many authorization servers issue opaque tokens rather than self-contained JWTs. Opaque tokens are random strings that carry no information themselves - the authorization server maintains their state and validation requires querying the server. FastMCP supports opaque token validation through OAuth 2.0 Token Introspection (RFC 7662). + +### Understanding Opaque Tokens + +Opaque tokens differ fundamentally from JWTs in their verification model. Where JWTs carry signed claims that can be validated locally, opaque tokens require network calls to the issuing authorization server for validation. The authorization server maintains token state and can revoke tokens immediately, providing stronger security guarantees for sensitive operations. + +This approach trades performance (network latency on each validation) for security and flexibility. Authorization servers can revoke opaque tokens instantly, implement complex authorization logic, and maintain detailed audit logs of token usage. Many enterprise OAuth providers default to opaque tokens for these security advantages. + +### Token Introspection Protocol + +RFC 7662 standardizes how resource servers validate opaque tokens. The protocol defines an introspection endpoint where resource servers authenticate using client credentials and receive token metadata including active status, scopes, expiration, and subject identity. + +FastMCP implements this protocol through the `IntrospectionTokenVerifier` class, handling authentication, request formatting, and response parsing according to the specification. + +```python +from fastmcp import FastMCP +from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier + +# Configure introspection with your OAuth provider +verifier = IntrospectionTokenVerifier( + introspection_url="https://auth.yourcompany.com/oauth/introspect", + client_id="mcp-resource-server", + client_secret="your-client-secret", + required_scopes=["api:read", "api:write"] +) + +mcp = FastMCP(name="Protected API", auth=verifier) +``` + +The verifier authenticates to the introspection endpoint using HTTP Basic Auth with your client credentials. When a request arrives with a bearer token, FastMCP queries the introspection endpoint to determine if the token is active and has sufficient scopes. + +## Development and Testing Development environments often need simpler token management without the complexity of full JWT infrastructure. FastMCP provides tools specifically designed for these scenarios. -#### Static Token Verification +### Static Token Verification Static token verification enables rapid development by accepting predefined tokens with associated claims. This approach eliminates the need for token generation infrastructure during development and testing. @@ -179,7 +210,8 @@ Clients can now authenticate using `Authorization: Bearer dev-alice-token` heade Static token verification stores tokens as plain text and should never be used in production environments. It's designed exclusively for development and testing scenarios. -#### Test Token Generation + +### Test Token Generation Test token generation helps when you need to test JWT verification without setting up complete identity infrastructure. FastMCP includes utilities for generating test key pairs and signed tokens. diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index c6dd8d10a..720272fb7 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -17,7 +17,7 @@ The `Context` object provides a clean interface to access MCP features within yo - **Resource Access**: Read data from resources registered with the server - **LLM Sampling**: Request the client's LLM to generate text based on provided messages - **User Elicitation**: Request structured input from users during tool execution -- **State Management**: Store and share data across middleware and tool calls within a request +- **State Management**: Store and share data between middleware and the handler within a single request - **Request Information**: Access metadata about the current request - **Server Access**: When needed, access the underlying FastMCP server instance @@ -34,6 +34,7 @@ To use the context object within any of your functions, simply add a parameter t - The context is optional - functions that don't need it can omit the parameter entirely. - Context methods are async, so your function usually needs to be async as well. - The type hint can be a union (`Context | None`) or use `Annotated[]` and it will still work properly. +- **Each MCP request receives a new context object.** Context is scoped to a single request; state or data set in one request will not be available in subsequent requests. - Context is only available during a request; attempting to use context methods outside a request will raise errors. If you need to debug or call your context methods outside of a request, you can type your variable as `Context | None=None` to avoid missing argument errors. #### Tools @@ -190,10 +191,14 @@ content = content_list[0].content -Store and share data across middleware and tool calls within a request. Context objects maintain a state dictionary that's especially useful for passing information from [middleware](/servers/middleware) to your tools. +Store and share data between middleware and handlers within a single MCP request. Each MCP request (such as calling a tool, reading a resource, listing tools, or listing resources) receives its own context object with isolated state. Context state is particularly useful for passing information from [middleware](/servers/middleware) to your handlers. To store a value in the context state, use `ctx.set_state(key, value)`. To retrieve a value, use `ctx.get_state(key)`. + +Context state is scoped to a single MCP request. Each operation (tool call, resource read, list operation, etc.) receives a new context object. State set during one request will not be available in subsequent requests. For persistent data storage across requests, use external storage mechanisms like databases, files, or in-memory caches. + + This simplified example shows how to use MCP middleware to store user info in the context state, and how to access that state in a tool: ```python {7-8, 16-17} diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index c9e73cfb8..380fe5a7f 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -387,12 +387,13 @@ FastMCP automatically converts tool return values into appropriate MCP content b - **`fastmcp.utilities.types.Image`**: Sent as `ImageContent` - **`fastmcp.utilities.types.Audio`**: Sent as `AudioContent` - **`fastmcp.utilities.types.File`**: Sent as base64-encoded `EmbeddedResource` -- **A list of any of the above**: Converts each item appropriately +- **MCP SDK content blocks**: Sent as-is +- **A list of any of the above**: Converts each item according to the above rules - **`None`**: Results in an empty response #### Media Helper Classes -For returning images, audio, and files, FastMCP provides helper classes that handle MIME type detection and base64 encoding automatically, returning them in MCP-native formats that meet the protocol's requirements: +FastMCP provides helper classes for returning images, audio, and files. When you return one of these classes, either directly or as part of a list, FastMCP automatically converts it to the appropriate MCP content block. For example, if you return a `fastmcp.utilities.types.Image` object, FastMCP will convert it to an MCP `ImageContent` block with the correct MIME type and base64 encoding. ```python from fastmcp.utilities.types import Image, Audio, File @@ -400,25 +401,30 @@ from fastmcp.utilities.types import Image, Audio, File @mcp.tool def get_chart() -> Image: """Generate a chart image.""" - # From file path - MIME type detected from extension return Image(path="chart.png") - # Or from raw bytes with explicit format - # return Image(data=image_bytes, format="png") - @mcp.tool -def get_recording() -> Audio: - """Get an audio recording.""" - return Audio(path="recording.wav") - # Or: Audio(data=audio_bytes, format="wav") - -@mcp.tool -def get_document() -> File: - """Retrieve a PDF document.""" - return File(path="report.pdf") - # Or: File(data=pdf_bytes, format="pdf", name="report") +def get_multiple_charts() -> list[Image]: + """Return multiple charts.""" + return [Image(path="chart1.png"), Image(path="chart2.png")] ``` + +Helper classes are only automatically converted to MCP content blocks when returned **directly** or as part of a **list**. For more complex containers like dicts, you can manually convert them to MCP types: + +```python +# ✅ Automatic conversion +return Image(path="chart.png") +return [Image(path="chart1.png"), "text content"] + +# ❌ Will not be automatically converted +return {"image": Image(path="chart.png")} + +# ✅ Manual conversion for nested use +return {"image": Image(path="chart.png").to_image_content()} +``` + + Each helper class accepts either `path=` or `data=` (mutually exclusive): - **`path`**: File path (string or Path object) - MIME type detected from extension - **`data`**: Raw bytes - requires `format=` parameter for MIME type diff --git a/scripts/auto_close_needs_mre.py b/scripts/auto_close_needs_mre.py new file mode 100644 index 000000000..6432d4882 --- /dev/null +++ b/scripts/auto_close_needs_mre.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "httpx", +# ] +# /// +""" +Auto-close issues that need MRE (Minimal Reproducible Example). + +This script runs on a schedule to automatically close issues that have been +marked as "needs MRE" and haven't received activity from the issue author +within 7 days. +""" + +import os +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +import httpx + + +@dataclass +class Issue: + """Represents a GitHub issue.""" + + number: int + title: str + state: str + created_at: str + user_id: int + user_login: str + body: str | None + + +@dataclass +class Comment: + """Represents a GitHub comment.""" + + id: int + body: str + created_at: str + user_id: int + user_login: str + + +@dataclass +class Event: + """Represents a GitHub issue event.""" + + event: str + created_at: str + label_name: str | None + + +class GitHubClient: + """Client for interacting with GitHub API.""" + + def __init__(self, token: str, owner: str, repo: str): + self.token = token + self.owner = owner + self.repo = repo + self.headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + } + self.base_url = f"https://api.github.com/repos/{owner}/{repo}" + + def get_issues_with_label( + self, label: str, page: int = 1, per_page: int = 100 + ) -> list[Issue]: + """Fetch open issues with a specific label.""" + url = f"{self.base_url}/issues" + issues = [] + + with httpx.Client() as client: + response = client.get( + url, + headers=self.headers, + params={ + "state": "open", + "labels": label, + "per_page": per_page, + "page": page, + }, + ) + + if response.status_code != 200: + print(f"Error fetching issues: {response.status_code}") + return issues + + data = response.json() + for item in data: + # Skip pull requests + if "pull_request" in item: + continue + + issues.append( + Issue( + number=item["number"], + title=item["title"], + state=item["state"], + created_at=item["created_at"], + user_id=item["user"]["id"], + user_login=item["user"]["login"], + body=item.get("body"), + ) + ) + + return issues + + def get_issue_events(self, issue_number: int) -> list[Event]: + """Fetch all events for an issue.""" + url = f"{self.base_url}/issues/{issue_number}/events" + events = [] + + with httpx.Client() as client: + page = 1 + while True: + response = client.get( + url, headers=self.headers, params={"page": page, "per_page": 100} + ) + + if response.status_code != 200: + break + + data = response.json() + if not data: + break + + for event_data in data: + label_name = None + if event_data["event"] == "labeled" and "label" in event_data: + label_name = event_data["label"]["name"] + + events.append( + Event( + event=event_data["event"], + created_at=event_data["created_at"], + label_name=label_name, + ) + ) + + page += 1 + if page > 10: # Safety limit + break + + return events + + def get_issue_comments(self, issue_number: int) -> list[Comment]: + """Fetch all comments for an issue.""" + url = f"{self.base_url}/issues/{issue_number}/comments" + comments = [] + + with httpx.Client() as client: + page = 1 + while True: + response = client.get( + url, headers=self.headers, params={"page": page, "per_page": 100} + ) + + if response.status_code != 200: + break + + data = response.json() + if not data: + break + + for comment_data in data: + comments.append( + Comment( + id=comment_data["id"], + body=comment_data["body"], + created_at=comment_data["created_at"], + user_id=comment_data["user"]["id"], + user_login=comment_data["user"]["login"], + ) + ) + + page += 1 + if page > 10: # Safety limit + break + + return comments + + def get_issue_timeline(self, issue_number: int) -> list[dict]: + """Fetch timeline events for an issue (includes issue edits).""" + url = f"{self.base_url}/issues/{issue_number}/timeline" + timeline = [] + + with httpx.Client() as client: + page = 1 + while True: + response = client.get( + url, + headers={ + **self.headers, + "Accept": "application/vnd.github.mockingbird-preview+json", + }, + params={"page": page, "per_page": 100}, + ) + + if response.status_code != 200: + break + + data = response.json() + if not data: + break + + timeline.extend(data) + + page += 1 + if page > 10: # Safety limit + break + + return timeline + + def close_issue(self, issue_number: int, comment: str) -> bool: + """Close an issue with a comment.""" + # First add the comment + comment_url = f"{self.base_url}/issues/{issue_number}/comments" + with httpx.Client() as client: + response = client.post( + comment_url, headers=self.headers, json={"body": comment} + ) + + if response.status_code != 201: + print(f"Failed to add comment to issue #{issue_number}") + return False + + # Then close the issue + issue_url = f"{self.base_url}/issues/{issue_number}" + with httpx.Client() as client: + response = client.patch( + issue_url, headers=self.headers, json={"state": "closed"} + ) + + return response.status_code == 200 + + +def find_label_application_date( + events: list[Event], label_name: str +) -> datetime | None: + """Find when a specific label was applied to an issue.""" + # Look for the most recent application of this label + for event in reversed(events): + if event.event == "labeled" and event.label_name == label_name: + return datetime.fromisoformat(event.created_at.replace("Z", "+00:00")) + return None + + +def has_author_activity_after( + issue: Issue, + comments: list[Comment], + timeline: list[dict], + after_date: datetime, +) -> bool: + """Check if the issue author had any activity after a specific date.""" + # Check for comments from author + for comment in comments: + if comment.user_id == issue.user_id: + comment_date = datetime.fromisoformat( + comment.created_at.replace("Z", "+00:00") + ) + if comment_date > after_date: + print( + f"Issue #{issue.number}: Author commented after label application" + ) + return True + + # Check for issue body edits from author + for event in timeline: + if event.get("event") == "renamed" or event.get("event") == "edited": + if event.get("actor", {}).get("id") == issue.user_id: + event_date = datetime.fromisoformat( + event["created_at"].replace("Z", "+00:00") + ) + if event_date > after_date: + print( + f"Issue #{issue.number}: Author edited issue after label application" + ) + return True + + return False + + +def should_close_as_needs_mre( + issue: Issue, + label_date: datetime, + comments: list[Comment], + timeline: list[dict], +) -> bool: + """Determine if an issue should be closed for needing an MRE.""" + # Check if label is old enough (7 days) + seven_days_ago = datetime.now(timezone.utc) - timedelta(days=7) + + if label_date > seven_days_ago: + return False + + # Check for author activity after the label was applied + if has_author_activity_after(issue, comments, timeline, label_date): + return False + + return True + + +def main(): + """Main entry point for auto-closing needs MRE issues.""" + print("[DEBUG] Starting auto-close needs MRE script") + + # Get environment variables + token = os.environ.get("GITHUB_TOKEN") + if not token: + raise ValueError("GITHUB_TOKEN environment variable is required") + + owner = os.environ.get("GITHUB_REPOSITORY_OWNER", "jlowin") + repo = os.environ.get("GITHUB_REPOSITORY_NAME", "fastmcp") + + print(f"[DEBUG] Repository: {owner}/{repo}") + + # Initialize client + client = GitHubClient(token, owner, repo) + + # Get issues with "needs MRE" label + all_issues = [] + page = 1 + + while page <= 20: # Safety limit + issues = client.get_issues_with_label("needs MRE", page=page) + if not issues: + break + all_issues.extend(issues) + page += 1 + + print(f"[DEBUG] Found {len(all_issues)} open issues with 'needs MRE' label") + + processed_count = 0 + closed_count = 0 + + for issue in all_issues: + processed_count += 1 + + if processed_count % 10 == 0: + print(f"[DEBUG] Processed {processed_count}/{len(all_issues)} issues") + + # Get events to find when label was applied + events = client.get_issue_events(issue.number) + label_date = find_label_application_date(events, "needs MRE") + + if not label_date: + print( + f"[DEBUG] Issue #{issue.number}: Could not find label application date" + ) + continue + + print( + f"[DEBUG] Issue #{issue.number}: Label applied on {label_date.isoformat()}" + ) + + # Get comments and timeline + comments = client.get_issue_comments(issue.number) + timeline = client.get_issue_timeline(issue.number) + + # Check if we should close + if should_close_as_needs_mre(issue, label_date, comments, timeline): + close_message = ( + "This issue is being automatically closed because we requested a minimal reproducible example (MRE) " + "7 days ago and haven't received a response from the issue author.\n\n" + "**If you can provide an MRE**, please add it as a comment and we'll reopen this issue. " + "An MRE should be a complete, runnable code snippet that demonstrates the problem.\n\n" + "**If this was closed in error**, please leave a comment explaining the situation and we'll reopen it." + ) + + if client.close_issue(issue.number, close_message): + print(f"[SUCCESS] Closed issue #{issue.number} (needs MRE)") + closed_count += 1 + else: + print(f"[ERROR] Failed to close issue #{issue.number}") + + print(f"[DEBUG] Processing complete. Closed {closed_count} issues needing MRE") + + +if __name__ == "__main__": + main() diff --git a/src/fastmcp/cli/claude.py b/src/fastmcp/cli/claude.py deleted file mode 100644 index f2d425225..000000000 --- a/src/fastmcp/cli/claude.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Claude app integration utilities.""" - -import json -import os -import sys -from pathlib import Path -from typing import Any - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment - -logger = get_logger(__name__) - - -def get_claude_config_path() -> Path | None: - """Get the Claude config directory based on platform.""" - if sys.platform == "win32": - path = Path(Path.home(), "AppData", "Roaming", "Claude") - elif sys.platform == "darwin": - path = Path(Path.home(), "Library", "Application Support", "Claude") - elif sys.platform.startswith("linux"): - path = Path( - os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude" - ) - else: - return None - - if path.exists(): - return path - return None - - -def update_claude_config( - file_spec: str, - server_name: str, - *, - with_editable: list[Path] | None = None, - with_packages: list[str] | None = None, - env_vars: dict[str, str] | None = None, -) -> bool: - """Add or update a FastMCP server in Claude's configuration. - - Args: - file_spec: Path to the server file, optionally with :object suffix - server_name: Name for the server in Claude's config - with_editable: Optional list of directories to install in editable mode - with_packages: Optional list of additional packages to install - env_vars: Optional dictionary of environment variables. These are merged with - any existing variables, with new values taking precedence. - - Raises: - RuntimeError: If Claude Desktop's config directory is not found, indicating - Claude Desktop may not be installed or properly set up. - """ - config_dir = get_claude_config_path() - if not config_dir: - raise RuntimeError( - "Claude Desktop config directory not found. Please ensure Claude Desktop" - " is installed and has been run at least once to initialize its config." - ) - - config_file = config_dir / "claude_desktop_config.json" - if not config_file.exists(): - try: - config_file.write_text("{}") - except Exception as e: - logger.error( - "Failed to create Claude config file", - extra={ - "error": str(e), - "config_file": str(config_file), - }, - ) - return False - - try: - config = json.loads(config_file.read_text()) - if "mcpServers" not in config: - config["mcpServers"] = {} - - # Always preserve existing env vars and merge with new ones - if ( - server_name in config["mcpServers"] - and "env" in config["mcpServers"][server_name] - ): - existing_env = config["mcpServers"][server_name]["env"] - if env_vars: - # New vars take precedence over existing ones - env_vars = {**existing_env, **env_vars} - else: - env_vars = existing_env - - env_config = UVEnvironment( - dependencies=(with_packages or []) + ["fastmcp"], - editable=[str(p) for p in with_editable] if with_editable else None, - ) - - # Convert file path to absolute before adding to command - # Split off any :object suffix first - if ":" in file_spec: - file_path, server_object = file_spec.rsplit(":", 1) - file_spec = f"{Path(file_path).resolve()}:{server_object}" - else: - file_spec = str(Path(file_spec).resolve()) - - # Build the full command - full_command = env_config.build_command(["fastmcp", "run", file_spec]) - - # Extract command and args for the config - server_config: dict[str, Any] = { - "command": full_command[0], - "args": full_command[1:], - } - - # Add environment variables if specified - if env_vars: - server_config["env"] = env_vars - - config["mcpServers"][server_name] = server_config - - config_file.write_text(json.dumps(config, indent=2)) - logger.info( - f"Added server '{server_name}' to Claude config", - extra={"config_file": str(config_file)}, - ) - return True - except Exception as e: - logger.error( - "Failed to update Claude config", - extra={ - "error": str(e), - "config_file": str(config_file), - }, - ) - return False diff --git a/src/fastmcp/client/logging.py b/src/fastmcp/client/logging.py index 6451591c5..2a8dd04e9 100644 --- a/src/fastmcp/client/logging.py +++ b/src/fastmcp/client/logging.py @@ -16,8 +16,8 @@ LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]] async def default_log_handler(message: LogMessage) -> None: """Default handler that properly routes server log messages to appropriate log levels.""" - msg = message.data.get("msg", str(message)) - extra = message.data.get("extra", {}) + # data can be any JSON-serializable type, not just a dict + data = message.data # Map MCP log levels to Python logging levels level_map = { @@ -40,8 +40,8 @@ async def default_log_handler(message: LogMessage) -> None: if message.logger: msg_prefix += f" ({message.logger})" - # Log with appropriate level and extra data - log_fn(msg=f"{msg_prefix}: {msg}", extra=extra) + # Log with appropriate level and data + log_fn(msg=f"{msg_prefix}: {data}") def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT: diff --git a/src/fastmcp/experimental/utilities/openapi/json_schema_converter.py b/src/fastmcp/experimental/utilities/openapi/json_schema_converter.py index fc7e9b5ba..2625aea08 100644 --- a/src/fastmcp/experimental/utilities/openapi/json_schema_converter.py +++ b/src/fastmcp/experimental/utilities/openapi/json_schema_converter.py @@ -176,6 +176,10 @@ def _convert_nullable_field(schema: dict[str, Any]) -> dict[str, Any]: # Wrap allOf in anyOf with null option result["anyOf"] = [{"allOf": result.pop("allOf")}, {"type": "null"}] + # Handle enum fields - add null to enum values if present + if "enum" in result and None not in result["enum"]: + result["enum"] = result["enum"] + [None] + return result diff --git a/src/fastmcp/experimental/utilities/openapi/parser.py b/src/fastmcp/experimental/utilities/openapi/parser.py index 799601a7e..bc81fc050 100644 --- a/src/fastmcp/experimental/utilities/openapi/parser.py +++ b/src/fastmcp/experimental/utilities/openapi/parser.py @@ -474,9 +474,22 @@ class OpenAPIParser( and media_type_obj.media_type_schema ): try: - schema_dict = self._extract_schema_as_dict( - media_type_obj.media_type_schema - ) + # Track if this is a top-level $ref before resolution + top_level_schema_name = None + media_schema = media_type_obj.media_type_schema + if isinstance(media_schema, self.reference_cls): + ref_str = media_schema.ref + if isinstance(ref_str, str) and ref_str.startswith( + "#/components/schemas/" + ): + top_level_schema_name = ref_str.split("/")[-1] + + schema_dict = self._extract_schema_as_dict(media_schema) + # Add marker for top-level schema if it was a ref + if top_level_schema_name: + schema_dict["x-fastmcp-top-level-schema"] = ( + top_level_schema_name + ) resp_info.content_schema[media_type_str] = schema_dict except ValueError as e: # Re-raise ValueError for external reference errors @@ -625,6 +638,13 @@ class OpenAPIParser( for response in responses.values(): if response.content_schema: for content_schema in response.content_schema.values(): + # Check if this schema was originally a top-level $ref + if "x-fastmcp-top-level-schema" in content_schema: + schema_name = content_schema["x-fastmcp-top-level-schema"] + if schema_name in all_schemas: + needed_schemas.add(schema_name) + + # Extract all dependencies (transitive refs within the schema) deps = self._extract_schema_dependencies( content_schema, all_schemas ) diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index dccdefb57..6569b6d3d 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -23,6 +23,7 @@ Example: from __future__ import annotations +from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -131,6 +132,7 @@ class AWSCognitoProvider(OIDCProxy): redirect_path: str | NotSetT = NotSet, required_scopes: list[str] | NotSetT = NotSet, allowed_client_redirect_uris: list[str] | NotSetT = NotSet, + client_storage: AsyncKeyValue | None = None, ): """Initialize AWS Cognito OAuth provider. @@ -144,6 +146,7 @@ class AWSCognitoProvider(OIDCProxy): required_scopes: Required Cognito scopes (defaults to ["openid"]) allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: An AsyncKeyValue-compatible store for client registrations, registrations are stored in memory if not provided """ settings = AWSCognitoProviderSettings.model_validate( @@ -205,6 +208,7 @@ class AWSCognitoProvider(OIDCProxy): base_url=settings.base_url, redirect_path=redirect_path_final, allowed_client_redirect_uris=allowed_client_redirect_uris_final, + client_storage=client_storage, ) logger.info( diff --git a/src/fastmcp/server/auth/providers/introspection.py b/src/fastmcp/server/auth/providers/introspection.py new file mode 100644 index 000000000..c9e865bd9 --- /dev/null +++ b/src/fastmcp/server/auth/providers/introspection.py @@ -0,0 +1,281 @@ +"""OAuth 2.0 Token Introspection (RFC 7662) provider for FastMCP. + +This module provides token verification for opaque tokens using the OAuth 2.0 +Token Introspection protocol defined in RFC 7662. It allows FastMCP servers to +validate tokens issued by authorization servers that don't use JWT format. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier + + # Verify opaque tokens via RFC 7662 introspection + verifier = IntrospectionTokenVerifier( + introspection_url="https://auth.example.com/oauth/introspect", + client_id="your-client-id", + client_secret="your-client-secret", + required_scopes=["read", "write"] + ) + + mcp = FastMCP("My Protected Server", auth=verifier) + ``` +""" + +from __future__ import annotations + +import base64 +import time +from typing import Any + +import httpx +from pydantic import AnyHttpUrl, SecretStr, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from fastmcp.server.auth import AccessToken, TokenVerifier +from fastmcp.settings import ENV_FILE +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.types import NotSet, NotSetT + +logger = get_logger(__name__) + + +class IntrospectionTokenVerifierSettings(BaseSettings): + """Settings for OAuth 2.0 Token Introspection verification.""" + + model_config = SettingsConfigDict( + env_prefix="FASTMCP_SERVER_AUTH_INTROSPECTION_", + env_file=ENV_FILE, + extra="ignore", + ) + + introspection_url: str | None = None + client_id: str | None = None + client_secret: SecretStr | None = None + timeout_seconds: int = 10 + required_scopes: list[str] | None = None + base_url: AnyHttpUrl | str | None = None + + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, v): + return parse_scopes(v) + + +class IntrospectionTokenVerifier(TokenVerifier): + """ + OAuth 2.0 Token Introspection verifier (RFC 7662). + + This verifier validates opaque tokens by calling an OAuth 2.0 token introspection + endpoint. Unlike JWT verification which is stateless, token introspection requires + a network call to the authorization server for each token validation. + + The verifier authenticates to the introspection endpoint using HTTP Basic Auth + with the provided client_id and client_secret, as specified in RFC 7662. + + Use this when: + - Your authorization server issues opaque (non-JWT) tokens + - You need to validate tokens from Auth0, Okta, Keycloak, or other OAuth servers + - Your tokens require real-time revocation checking + - Your authorization server supports RFC 7662 introspection + + Example: + ```python + verifier = IntrospectionTokenVerifier( + introspection_url="https://auth.example.com/oauth/introspect", + client_id="my-service", + client_secret="secret-key", + required_scopes=["api:read"] + ) + ``` + """ + + def __init__( + self, + *, + introspection_url: str | NotSetT = NotSet, + client_id: str | NotSetT = NotSet, + client_secret: str | NotSetT = NotSet, + timeout_seconds: int | NotSetT = NotSet, + required_scopes: list[str] | None | NotSetT = NotSet, + base_url: AnyHttpUrl | str | None | NotSetT = NotSet, + ): + """ + Initialize the introspection token verifier. + + Args: + introspection_url: URL of the OAuth 2.0 token introspection endpoint + client_id: OAuth client ID for authenticating to the introspection endpoint + client_secret: OAuth client secret for authenticating to the introspection endpoint + timeout_seconds: HTTP request timeout in seconds (default: 10) + required_scopes: Required scopes for all tokens (optional) + base_url: Base URL for TokenVerifier protocol + """ + settings = IntrospectionTokenVerifierSettings.model_validate( + { + k: v + for k, v in { + "introspection_url": introspection_url, + "client_id": client_id, + "client_secret": client_secret, + "timeout_seconds": timeout_seconds, + "required_scopes": required_scopes, + "base_url": base_url, + }.items() + if v is not NotSet + } + ) + + if not settings.introspection_url: + raise ValueError( + "introspection_url is required - set via parameter or " + "FASTMCP_SERVER_AUTH_INTROSPECTION_INTROSPECTION_URL" + ) + if not settings.client_id: + raise ValueError( + "client_id is required - set via parameter or " + "FASTMCP_SERVER_AUTH_INTROSPECTION_CLIENT_ID" + ) + if not settings.client_secret: + raise ValueError( + "client_secret is required - set via parameter or " + "FASTMCP_SERVER_AUTH_INTROSPECTION_CLIENT_SECRET" + ) + + super().__init__( + base_url=settings.base_url, required_scopes=settings.required_scopes + ) + + self.introspection_url = settings.introspection_url + self.client_id = settings.client_id + self.client_secret = settings.client_secret.get_secret_value() + self.timeout_seconds = settings.timeout_seconds + self.logger = get_logger(__name__) + + def _create_basic_auth_header(self) -> str: + """Create HTTP Basic Auth header value from client credentials.""" + credentials = f"{self.client_id}:{self.client_secret}" + encoded = base64.b64encode(credentials.encode("utf-8")).decode("utf-8") + return f"Basic {encoded}" + + def _extract_scopes(self, introspection_response: dict[str, Any]) -> list[str]: + """ + Extract scopes from introspection response. + + RFC 7662 allows scopes to be returned as either: + - A space-separated string in the 'scope' field + - An array of strings in the 'scope' field (less common but valid) + """ + scope_value = introspection_response.get("scope") + + if scope_value is None: + return [] + + # Handle string (space-separated) scopes + if isinstance(scope_value, str): + return [s.strip() for s in scope_value.split() if s.strip()] + + # Handle array of scopes + if isinstance(scope_value, list): + return [str(s) for s in scope_value if s] + + return [] + + async def verify_token(self, token: str) -> AccessToken | None: + """ + Verify a bearer token using OAuth 2.0 Token Introspection (RFC 7662). + + This method makes a POST request to the introspection endpoint with the token, + authenticated using HTTP Basic Auth with the client credentials. + + Args: + token: The opaque token string to validate + + Returns: + AccessToken object if valid and active, None if invalid, inactive, or expired + """ + try: + async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: + # Prepare introspection request per RFC 7662 + auth_header = self._create_basic_auth_header() + + response = await client.post( + self.introspection_url, + data={ + "token": token, + "token_type_hint": "access_token", + }, + headers={ + "Authorization": auth_header, + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + ) + + # Check for HTTP errors + if response.status_code != 200: + self.logger.debug( + "Token introspection failed: HTTP %d - %s", + response.status_code, + response.text[:200] if response.text else "", + ) + return None + + introspection_data = response.json() + + # Check if token is active (required field per RFC 7662) + if not introspection_data.get("active", False): + self.logger.debug("Token introspection returned active=false") + return None + + # Extract client_id (should be present for active tokens) + client_id = introspection_data.get( + "client_id" + ) or introspection_data.get("sub", "unknown") + + # Extract expiration time + exp = introspection_data.get("exp") + if exp: + # Validate expiration (belt and suspenders - server should set active=false) + if exp < time.time(): + self.logger.debug( + "Token validation failed: expired token for client %s", + client_id, + ) + return None + + # Extract scopes + scopes = self._extract_scopes(introspection_data) + + # Check required scopes + if self.required_scopes: + token_scopes = set(scopes) + required_scopes = set(self.required_scopes) + if not required_scopes.issubset(token_scopes): + self.logger.debug( + "Token missing required scopes. Has: %s, Required: %s", + token_scopes, + required_scopes, + ) + return None + + # Create AccessToken with introspection response data + return AccessToken( + token=token, + client_id=str(client_id), + scopes=scopes, + expires_at=int(exp) if exp else None, + claims=introspection_data, # Store full response for extensibility + ) + + except httpx.TimeoutException: + self.logger.debug( + "Token introspection timed out after %d seconds", self.timeout_seconds + ) + return None + except httpx.RequestError as e: + self.logger.debug("Token introspection request failed: %s", e) + return None + except Exception as e: + self.logger.debug("Token introspection error: %s", e) + return None diff --git a/tests/client/test_logs.py b/tests/client/test_logs.py index f7f728515..f04af4338 100644 --- a/tests/client/test_logs.py +++ b/tests/client/test_logs.py @@ -91,7 +91,7 @@ class TestClientLogs: class TestDefaultLogHandler: - """Tests for default_log_handler bug fix (issue #1394).""" + """Tests for default_log_handler with data as any JSON-serializable type.""" async def test_default_handler_routes_to_correct_levels(self): """Test that default_log_handler routes server logs to appropriate Python log levels.""" @@ -129,11 +129,11 @@ class TestDefaultLogHandler: # Reset mocks mock_logger.reset_mock() - # Create log message + # Create log message with data as a string log_msg = LoggingMessageNotificationParams( level=level, # type: ignore[arg-type] logger="test.logger", - data={"msg": msg, "extra": {"test_key": "test_value"}}, + data=msg, ) # Call handler @@ -141,8 +141,7 @@ class TestDefaultLogHandler: # Verify correct method was called expected_method.assert_called_once_with( - msg=f"Received {level.upper()} from server (test.logger): {msg}", - extra={"test_key": "test_value"}, + msg=f"Received {level.upper()} from server (test.logger): {msg}" ) async def test_default_handler_without_logger_name(self): @@ -159,17 +158,17 @@ class TestDefaultLogHandler: log_msg = LoggingMessageNotificationParams( level="info", logger=None, - data={"msg": "Message without logger", "extra": {}}, + data="Message without logger", ) await default_log_handler(log_msg) mock_logger.info.assert_called_once_with( - msg="Received INFO from server: Message without logger", extra={} + msg="Received INFO from server: Message without logger" ) - async def test_default_handler_with_missing_msg(self): - """Test that default_log_handler handles missing 'msg' gracefully.""" + async def test_default_handler_with_dict_data(self): + """Test that default_log_handler handles dict data correctly.""" from unittest.mock import MagicMock, patch from mcp.types import LoggingMessageNotificationParams @@ -182,13 +181,62 @@ class TestDefaultLogHandler: log_msg = LoggingMessageNotificationParams( level="info", logger="test.logger", - data={"extra": {"key": "value"}}, # Missing 'msg' key + data={"key": "value", "count": 42}, ) await default_log_handler(log_msg) - # Should use str(message) as fallback + # Should log the entire dict as a string mock_logger.info.assert_called_once() call_args = mock_logger.info.call_args - assert "Received INFO from server" in call_args[1]["msg"] - assert call_args[1]["extra"] == {"key": "value"} + assert "Received INFO from server (test.logger):" in call_args[1]["msg"] + assert "key" in call_args[1]["msg"] + assert "value" in call_args[1]["msg"] + + async def test_default_handler_with_list_data(self): + """Test that default_log_handler handles list data correctly.""" + from unittest.mock import MagicMock, patch + + from mcp.types import LoggingMessageNotificationParams + + from fastmcp.client.logging import default_log_handler + + with patch("fastmcp.client.logging.from_server_logger") as mock_logger: + mock_logger.warning = MagicMock() + + log_msg = LoggingMessageNotificationParams( + level="warning", + logger="test.logger", + data=["item1", "item2", "item3"], + ) + + await default_log_handler(log_msg) + + # Should log the entire list as a string + mock_logger.warning.assert_called_once() + call_args = mock_logger.warning.call_args + assert "Received WARNING from server (test.logger):" in call_args[1]["msg"] + assert "item1" in call_args[1]["msg"] + + async def test_default_handler_with_number_data(self): + """Test that default_log_handler handles numeric data correctly.""" + from unittest.mock import MagicMock, patch + + from mcp.types import LoggingMessageNotificationParams + + from fastmcp.client.logging import default_log_handler + + with patch("fastmcp.client.logging.from_server_logger") as mock_logger: + mock_logger.error = MagicMock() + + log_msg = LoggingMessageNotificationParams( + level="error", + logger=None, + data=404, + ) + + await default_log_handler(log_msg) + + mock_logger.error.assert_called_once_with( + msg="Received ERROR from server: 404" + ) diff --git a/tests/client/test_openapi_experimental.py b/tests/client/test_openapi_experimental.py index 6e32024b3..c4b0636a9 100644 --- a/tests/client/test_openapi_experimental.py +++ b/tests/client/test_openapi_experimental.py @@ -191,6 +191,7 @@ async def test_client_with_excluded_header_is_ignored(sse_server: str): assert headers["host"] == "fastapi" +@pytest.mark.flaky(retries=2, delay=1) async def test_client_headers_proxy(proxy_server: str): """ Test that client headers are passed through the proxy to the remove server. diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_nullable_fields.py b/tests/experimental/openapi_parser/utilities/openapi/test_nullable_fields.py index 50d91c1ea..171147104 100644 --- a/tests/experimental/openapi_parser/utilities/openapi/test_nullable_fields.py +++ b/tests/experimental/openapi_parser/utilities/openapi/test_nullable_fields.py @@ -1,5 +1,8 @@ """Tests for nullable field handling in OpenAPI schemas.""" +import pytest +from jsonschema import ValidationError, validate + from fastmcp.experimental.utilities.openapi.json_schema_converter import ( convert_openapi_schema_to_json_schema, ) @@ -243,3 +246,130 @@ class TestHandleNullableFields: } result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") assert result == expected + + def test_nullable_enum_field(self): + """Test nullable enum field - issue #2082.""" + input_schema = { + "type": "string", + "nullable": True, + "enum": ["VALUE1", "VALUE2", "VALUE3"], + } + expected = { + "type": ["string", "null"], + "enum": ["VALUE1", "VALUE2", "VALUE3", None], + } + result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") + assert result == expected + + def test_nullable_enum_already_contains_null(self): + """Test nullable enum that already contains None.""" + input_schema = { + "type": "string", + "nullable": True, + "enum": ["VALUE1", "VALUE2", None], + } + expected = { + "type": ["string", "null"], + "enum": ["VALUE1", "VALUE2", None], + } + result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") + assert result == expected + + def test_nullable_enum_without_type(self): + """Test nullable enum without explicit type field.""" + input_schema = { + "nullable": True, + "enum": ["VALUE1", "VALUE2", "VALUE3"], + } + expected = { + "enum": ["VALUE1", "VALUE2", "VALUE3", None], + } + result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") + assert result == expected + + def test_non_nullable_enum_unchanged(self): + """Test that enum without nullable is unchanged.""" + input_schema = { + "type": "string", + "enum": ["VALUE1", "VALUE2", "VALUE3"], + } + expected = { + "type": "string", + "enum": ["VALUE1", "VALUE2", "VALUE3"], + } + result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") + assert result == expected + + def test_property_level_nullable_enum(self): + """Test nullable enum in object properties.""" + input_schema = { + "type": "object", + "properties": { + "status": { + "type": "string", + "nullable": True, + "enum": ["ACTIVE", "INACTIVE", "PENDING"], + }, + "name": {"type": "string"}, + }, + } + expected = { + "type": "object", + "properties": { + "status": { + "type": ["string", "null"], + "enum": ["ACTIVE", "INACTIVE", "PENDING", None], + }, + "name": {"type": "string"}, + }, + } + result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") + assert result == expected + + def test_nullable_integer_enum(self): + """Test nullable enum with integer values.""" + input_schema = { + "type": "integer", + "nullable": True, + "enum": [1, 2, 3], + } + expected = { + "type": ["integer", "null"], + "enum": [1, 2, 3, None], + } + result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0") + assert result == expected + + +class TestNullableFieldValidation: + """Test that converted schemas validate correctly with jsonschema.""" + + def test_nullable_string_validates(self): + """Test that nullable string validates both null and string values.""" + openapi_schema = {"type": "string", "nullable": True} + json_schema = convert_openapi_schema_to_json_schema(openapi_schema, "3.0.0") + + # Both null and string should validate + validate(instance=None, schema=json_schema) + validate(instance="test", schema=json_schema) + + # Other types should fail + with pytest.raises(ValidationError): + validate(instance=123, schema=json_schema) + + def test_nullable_enum_validates(self): + """Test that nullable enum validates null, enum values, and rejects invalid values.""" + openapi_schema = { + "type": "string", + "nullable": True, + "enum": ["VALUE1", "VALUE2", "VALUE3"], + } + json_schema = convert_openapi_schema_to_json_schema(openapi_schema, "3.0.0") + + # Null and enum values should validate + validate(instance=None, schema=json_schema) + validate(instance="VALUE1", schema=json_schema) + + # Invalid values should fail + with pytest.raises(ValidationError): + validate(instance="INVALID", schema=json_schema) diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_transitive_references.py b/tests/experimental/openapi_parser/utilities/openapi/test_transitive_references.py index da1000da8..e3cae4ae1 100644 --- a/tests/experimental/openapi_parser/utilities/openapi/test_transitive_references.py +++ b/tests/experimental/openapi_parser/utilities/openapi/test_transitive_references.py @@ -786,3 +786,57 @@ class TestTransitiveAndNestedReferences: assert len(overlap) == 0, ( f"Found overlapping schemas between input and output: {overlap}" ) + + def test_issue_2087_top_level_response_ref_includes_all_nested_schemas(self): + """Issue #2087: Top-level response $ref must include itself in response_schemas.""" + spec = { + "openapi": "3.0.1", + "info": {"title": "Test", "version": "1.0"}, + "paths": { + "/persons": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonList" + } + } + }, + } + } + } + } + }, + "components": { + "schemas": { + "PersonList": { + "properties": { + "Items": { + "type": "array", + "items": {"$ref": "#/components/schemas/Person"}, + } + } + }, + "Person": { + "properties": { + "Name": {"$ref": "#/components/schemas/Name"}, + "Job": {"$ref": "#/components/schemas/Job"}, + } + }, + "Name": {"properties": {"First": {"type": "string"}}}, + "Job": {"properties": {"Company": {"type": "string"}}}, + } + }, + } + + routes = parse_openapi_to_http_routes(spec) + route = routes[0] + + # Bug was: PersonList missing from response_schemas despite being top-level ref + assert "PersonList" in route.response_schemas + assert "Person" in route.response_schemas + assert "Name" in route.response_schemas + assert "Job" in route.response_schemas diff --git a/tests/server/auth/providers/test_introspection.py b/tests/server/auth/providers/test_introspection.py new file mode 100644 index 000000000..c60bef9d1 --- /dev/null +++ b/tests/server/auth/providers/test_introspection.py @@ -0,0 +1,463 @@ +"""Tests for OAuth 2.0 Token Introspection verifier (RFC 7662).""" + +import base64 +import time +from typing import Any + +import pytest +from pydantic import SecretStr +from pytest_httpx import HTTPXMock + +from fastmcp.server.auth.providers.introspection import ( + IntrospectionTokenVerifier, + IntrospectionTokenVerifierSettings, +) + + +class TestIntrospectionTokenVerifierSettings: + """Test settings loading and validation.""" + + def test_settings_from_parameters(self): + """Test creating settings from parameters.""" + settings = IntrospectionTokenVerifierSettings( + introspection_url="https://auth.example.com/introspect", + client_id="test-client", + client_secret=SecretStr("test-secret"), + timeout_seconds=5, + required_scopes=["read", "write"], + ) + + assert settings.introspection_url == "https://auth.example.com/introspect" + assert settings.client_id == "test-client" + assert settings.client_secret + assert settings.client_secret.get_secret_value() == "test-secret" + assert settings.timeout_seconds == 5 + assert settings.required_scopes == ["read", "write"] + + def test_settings_default_timeout(self): + """Test default timeout value.""" + settings = IntrospectionTokenVerifierSettings( + introspection_url="https://auth.example.com/introspect", + client_id="test-client", + client_secret=SecretStr("test-secret"), + ) + + assert settings.timeout_seconds == 10 + + def test_settings_parse_scopes_from_string(self): + """Test scope parsing from comma-separated string.""" + settings = IntrospectionTokenVerifierSettings( + introspection_url="https://auth.example.com/introspect", + client_id="test-client", + client_secret=SecretStr("test-secret"), + required_scopes="read,write,admin", # type: ignore + ) + + assert settings.required_scopes == ["read", "write", "admin"] + + +class TestIntrospectionTokenVerifier: + """Test core token verification logic.""" + + @pytest.fixture + def verifier(self) -> IntrospectionTokenVerifier: + """Create a basic introspection verifier for testing.""" + return IntrospectionTokenVerifier( + introspection_url="https://auth.example.com/oauth/introspect", + client_id="test-client", + client_secret="test-secret", + timeout_seconds=5, + ) + + @pytest.fixture + def verifier_with_required_scopes(self) -> IntrospectionTokenVerifier: + """Create verifier with required scopes.""" + return IntrospectionTokenVerifier( + introspection_url="https://auth.example.com/oauth/introspect", + client_id="test-client", + client_secret="test-secret", + required_scopes=["read", "write"], + ) + + def test_initialization(self): + """Test verifier initialization.""" + verifier = IntrospectionTokenVerifier( + introspection_url="https://auth.example.com/oauth/introspect", + client_id="test-client", + client_secret="test-secret", + ) + + assert verifier.introspection_url == "https://auth.example.com/oauth/introspect" + assert verifier.client_id == "test-client" + assert verifier.client_secret == "test-secret" + assert verifier.timeout_seconds == 10 + + def test_initialization_requires_introspection_url(self): + """Test that introspection_url is required.""" + with pytest.raises(ValueError, match="introspection_url is required"): + IntrospectionTokenVerifier( + client_id="test-client", + client_secret="test-secret", + ) + + def test_initialization_requires_client_id(self): + """Test that client_id is required.""" + with pytest.raises(ValueError, match="client_id is required"): + IntrospectionTokenVerifier( + introspection_url="https://auth.example.com/oauth/introspect", + client_secret="test-secret", + ) + + def test_initialization_requires_client_secret(self): + """Test that client_secret is required.""" + with pytest.raises(ValueError, match="client_secret is required"): + IntrospectionTokenVerifier( + introspection_url="https://auth.example.com/oauth/introspect", + client_id="test-client", + ) + + def test_create_basic_auth_header(self, verifier: IntrospectionTokenVerifier): + """Test HTTP Basic Auth header creation.""" + auth_header = verifier._create_basic_auth_header() + + # Decode and verify + assert auth_header.startswith("Basic ") + encoded = auth_header[6:] + decoded = base64.b64decode(encoded).decode("utf-8") + assert decoded == "test-client:test-secret" + + def test_extract_scopes_from_string(self, verifier: IntrospectionTokenVerifier): + """Test scope extraction from space-separated string.""" + response = {"scope": "read write admin"} + scopes = verifier._extract_scopes(response) + + assert scopes == ["read", "write", "admin"] + + def test_extract_scopes_from_array(self, verifier: IntrospectionTokenVerifier): + """Test scope extraction from array.""" + response = {"scope": ["read", "write", "admin"]} + scopes = verifier._extract_scopes(response) + + assert scopes == ["read", "write", "admin"] + + def test_extract_scopes_missing(self, verifier: IntrospectionTokenVerifier): + """Test scope extraction when scope field is missing.""" + response: dict[str, Any] = {} + scopes = verifier._extract_scopes(response) + + assert scopes == [] + + def test_extract_scopes_with_extra_whitespace( + self, verifier: IntrospectionTokenVerifier + ): + """Test scope extraction handles extra whitespace.""" + response = {"scope": " read write admin "} + scopes = verifier._extract_scopes(response) + + assert scopes == ["read", "write", "admin"] + + async def test_valid_token_verification( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test successful token verification.""" + # Mock introspection endpoint + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={ + "active": True, + "client_id": "user-123", + "scope": "read write", + "exp": int(time.time()) + 3600, + "iat": int(time.time()), + "sub": "user-123", + "username": "testuser", + }, + ) + + access_token = await verifier.verify_token("test-token") + + assert access_token is not None + assert access_token.client_id == "user-123" + assert access_token.scopes == ["read", "write"] + assert access_token.expires_at is not None + assert access_token.claims["active"] is True + assert access_token.claims["username"] == "testuser" + + async def test_inactive_token_returns_none( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test that inactive tokens return None.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={"active": False}, + ) + + access_token = await verifier.verify_token("expired-token") + + assert access_token is None + + async def test_expired_token_returns_none( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test that expired tokens return None.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={ + "active": True, + "client_id": "user-123", + "scope": "read", + "exp": int(time.time()) - 3600, # Expired 1 hour ago + }, + ) + + access_token = await verifier.verify_token("expired-token") + + assert access_token is None + + async def test_token_without_expiration( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test token without expiration field.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={ + "active": True, + "client_id": "user-123", + "scope": "read", + }, + ) + + access_token = await verifier.verify_token("test-token") + + assert access_token is not None + assert access_token.expires_at is None + + async def test_token_without_scopes( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test token without scope field.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={ + "active": True, + "client_id": "user-123", + }, + ) + + access_token = await verifier.verify_token("test-token") + + assert access_token is not None + assert access_token.scopes == [] + + async def test_required_scopes_validation( + self, + verifier_with_required_scopes: IntrospectionTokenVerifier, + httpx_mock: HTTPXMock, + ): + """Test that required scopes are validated.""" + # Token with insufficient scopes + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={ + "active": True, + "client_id": "user-123", + "scope": "read", # Missing 'write' + }, + ) + + access_token = await verifier_with_required_scopes.verify_token("test-token") + + assert access_token is None + + async def test_required_scopes_validation_success( + self, + verifier_with_required_scopes: IntrospectionTokenVerifier, + httpx_mock: HTTPXMock, + ): + """Test successful validation with required scopes.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={ + "active": True, + "client_id": "user-123", + "scope": "read write admin", # Has all required scopes + }, + ) + + access_token = await verifier_with_required_scopes.verify_token("test-token") + + assert access_token is not None + assert set(access_token.scopes) >= {"read", "write"} + + async def test_http_error_returns_none( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test that HTTP errors return None.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + status_code=500, + text="Internal Server Error", + ) + + access_token = await verifier.verify_token("test-token") + + assert access_token is None + + async def test_authentication_failure_returns_none( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test that authentication failures return None.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + status_code=401, + text="Unauthorized", + ) + + access_token = await verifier.verify_token("test-token") + + assert access_token is None + + async def test_timeout_returns_none( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test that timeouts return None.""" + from httpx import TimeoutException + + httpx_mock.add_exception( + TimeoutException("Request timed out"), + url="https://auth.example.com/oauth/introspect", + ) + + access_token = await verifier.verify_token("test-token") + + assert access_token is None + + async def test_malformed_json_returns_none( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test that malformed JSON responses return None.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + status_code=200, + text="not json", + ) + + access_token = await verifier.verify_token("test-token") + + assert access_token is None + + async def test_request_includes_correct_headers( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test that the request includes correct headers and auth.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={"active": True, "client_id": "user-123"}, + ) + + await verifier.verify_token("test-token") + + # Verify request was made with correct parameters + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert "Authorization" in request.headers + assert request.headers["Authorization"].startswith("Basic ") + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + assert request.headers["Accept"] == "application/json" + + async def test_request_includes_token_and_hint( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test that the request includes token and token_type_hint.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={"active": True, "client_id": "user-123"}, + ) + + await verifier.verify_token("my-test-token") + + request = httpx_mock.get_request() + assert request is not None + + # Parse form data + body = request.content.decode("utf-8") + assert "token=my-test-token" in body + assert "token_type_hint=access_token" in body + + async def test_client_id_fallback_to_sub( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test that client_id falls back to sub if not present.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={ + "active": True, + "sub": "user-456", + "scope": "read", + }, + ) + + access_token = await verifier.verify_token("test-token") + + assert access_token is not None + assert access_token.client_id == "user-456" + + async def test_client_id_defaults_to_unknown( + self, verifier: IntrospectionTokenVerifier, httpx_mock: HTTPXMock + ): + """Test that client_id defaults to 'unknown' if neither client_id nor sub present.""" + httpx_mock.add_response( + url="https://auth.example.com/oauth/introspect", + method="POST", + json={ + "active": True, + "scope": "read", + }, + ) + + access_token = await verifier.verify_token("test-token") + + assert access_token is not None + assert access_token.client_id == "unknown" + + +class TestIntrospectionTokenVerifierIntegration: + """Integration tests with FastMCP server.""" + + async def test_verifier_used_by_fastmcp(self): + """Test that IntrospectionTokenVerifier can be used as FastMCP auth.""" + from fastmcp import FastMCP + + # Create verifier + verifier = IntrospectionTokenVerifier( + introspection_url="https://auth.example.com/oauth/introspect", + client_id="test-client", + client_secret="test-secret", + ) + + # Create protected server - should work without errors + mcp = FastMCP("Test Server", auth=verifier) + + @mcp.tool() + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + # Verify the auth is set correctly + assert mcp.auth is verifier + tools = await mcp.get_tools() + assert len(list(tools)) == 1 diff --git a/tests/server/auth/test_oauth_consent_flow.py b/tests/server/auth/test_oauth_consent_flow.py index 41eff87ca..45d3cdb34 100644 --- a/tests/server/auth/test_oauth_consent_flow.py +++ b/tests/server/auth/test_oauth_consent_flow.py @@ -231,10 +231,12 @@ class TestServerSideStorage: csrf_token = secrets.token_urlsafe(32) # Approve consent with CSRF token + # Set cookies on client instance to avoid deprecation warning + for k, v in consent_response.cookies.items(): + test_client.cookies.set(k, v) approval_response = test_client.post( "/consent", data={"action": "approve", "txn": txn_id, "csrf_token": csrf_token}, - cookies=consent_response.cookies, follow_redirects=False, )