From 2fa5e49e41bf14a6655259108dacc10383fd19dc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 2 Sep 2025 19:39:27 -0400 Subject: [PATCH 1/3] Update quickstart --- docs/docs.json | 22 +---- docs/getting-started/quickstart.mdx | 126 +++++++++++----------------- 2 files changed, 54 insertions(+), 94 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 88a56866b..d8eaf56d9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -119,10 +119,7 @@ { "group": "Essentials", "icon": "cube", - "pages": [ - "clients/client", - "clients/transports" - ] + "pages": ["clients/client", "clients/transports"] }, { "group": "Core Operations", @@ -148,10 +145,7 @@ { "group": "Authentication", "icon": "user-shield", - "pages": [ - "clients/auth/oauth", - "clients/auth/bearer" - ] + "pages": ["clients/auth/oauth", "clients/auth/bearer"] } ] }, @@ -230,17 +224,7 @@ }, { "anchor": "What's New", - "pages": [ - "updates", - "changelog" - ] - }, - { - "anchor": "Community", - "icon": "users", - "pages": [ - "community/showcase" - ] + "pages": ["updates", "changelog"] } ], "tab": "Documentation" diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index b791918ea..31859b169 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -38,40 +38,13 @@ def greet(name: str) -> str: ``` -## Test the Server - - -To test the server, create a FastMCP client and point it at the server object. - -```python my_server.py {1-2, 10-17} -import asyncio -from fastmcp import FastMCP, Client - -mcp = FastMCP("My MCP Server") - -@mcp.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -client = Client(mcp) - -async def call_tool(name: str): - async with client: - result = await client.call_tool("greet", {"name": name}) - print(result) - -asyncio.run(call_tool("Ford")) -``` - -There are a few things to note here: -- Clients are asynchronous, so we need to use `asyncio.run` to run the client. -- We must enter a client context (`async with client:`) before using the client. You can make multiple client calls within the same context. - ## Run the Server -In order to run the server with Python, we need to add a `run` statement to the `__main__` block of the server file. +The simplest way to run your FastMCP server is to call its `run()` method. You can choose between different transports, like `stdio` for local servers, or `http` for remote access: -```python my_server.py {9-10} + + +```python my_server.py (stdio) {9, 10} from fastmcp import FastMCP mcp = FastMCP("My MCP Server") @@ -84,25 +57,52 @@ if __name__ == "__main__": mcp.run() ``` -This lets us run the server with `python my_server.py`, using the default `stdio` transport, which is the standard way to expose an MCP server to a client. +```python my_server.py (HTTP) {9, 10} +from fastmcp import FastMCP + +mcp = FastMCP("My MCP Server") + +@mcp.tool +def greet(name: str) -> str: + return f"Hello, {name}!" + +if __name__ == "__main__": + mcp.run(transport="http", port=8000) +``` + + + +This lets us run the server with `python my_server.py`. The stdio transport is the traditional way to connect MCP servers to clients, while the HTTP transport enables remote connections. Why do we need the `if __name__ == "__main__":` block? -Within the FastMCP ecosystem, this line may be unnecessary. However, including it ensures that your FastMCP server runs for all users and clients in a consistent way and is therefore recommended as best practice. +The `__main__` block is recommended for consistency and compatibility, ensuring your server works with all MCP clients that execute your server file as a script. Users who will exclusively run their server with the FastMCP CLI can omit it, as the CLI imports the server object directly. -### Interacting with the Python server +### Using the FastMCP CLI -Now that the server can be executed with `python my_server.py`, we can interact with it like any other MCP server. +You can also use the `fastmcp run` command to start your server. Note that the FastMCP CLI **does not** execute the `__main__` block of your server file. Instead, it imports your server object and runs it with whatever transport and options you provide. -In a new file, create a client and point it at the server file: +For example, to run this server with the default stdio transport (no matter how you called `mcp.run()`), you can use the following command: +```bash +fastmcp run my_server.py:mcp +``` + +To run this server with the HTTP transport, you can use the following command: +```bash +fastmcp run my_server.py:mcp --transport http --port 8000 +``` + +## Call Your Server + +Once your server is running with HTTP transport, you can connect to it with a FastMCP client or any LLM client that supports the MCP protocol: ```python my_client.py import asyncio from fastmcp import Client -client = Client("my_server.py") +client = Client("http://localhost:8000") async def call_tool(name: str): async with client: @@ -112,50 +112,26 @@ async def call_tool(name: str): asyncio.run(call_tool("Ford")) ``` - - -### Using the FastMCP CLI - -To have FastMCP run the server for us, we can use the `fastmcp run` command. This will start the server and keep it running until it is stopped. By default, it will use the `stdio` transport, which is a simple text-based protocol for interacting with the server. - -```bash -fastmcp run my_server.py:mcp -``` - -Note that FastMCP *does not* require the `__main__` block in the server file, and will ignore it if it is present. Instead, it looks for the server object provided in the CLI command (here, `mcp`). If no server object is provided, `fastmcp run` will automatically search for servers called "mcp", "app", or "server" in the file. - - -We pointed our client at the server file, which is recognized as a Python MCP server and executed with `python my_server.py` by default. This executes the `__main__` block of the server file. There are other ways to run the server, which are described in the [server configuration](/servers/server#running-the-server) guide. - +Note that: +- FastMCP clients are asynchronous, so we need to use `asyncio.run` to run the client +- We must enter a client context (`async with client:`) before using the client +- You can make multiple client calls within the same context ## Deploy to FastMCP Cloud -FastMCP Cloud allows you to take a local MCP server into production, in minutes. -### Prerequisites +[FastMCP Cloud](https://fastmcp.cloud) is a hosting service run by the FastMCP team at [Prefect](https://www.prefect.io/fastmcp). It is optimized to deploy authenticated FastMCP servers as quickly as possible, giving you a secure URL that you can plug into any LLM client. -You'll need the following to deploy a server to FastMCP Cloud: + +Please note that FastMCP Cloud is a commercial service, though it is completely free for most personal servers. + -- A FastMCP Cloud Account -- A GitHub Account +To deploy your server, you'll need a [GitHub account](https://github.com). Once you have one, you can deploy your server in three steps: -FastMCP Cloud is in beta, and you may need to join the [Join the waitlist](https://www.fastmcp.cloud/), if you haven't already. +1. Push your `my_server.py` file to a GitHub repository +2. Sign in to [FastMCP Cloud](https://fastmcp.cloud) with your GitHub account +3. Create a new project from your repository and enter `my_server.py:mcp` as the server entrypoint -### Deploy +That's it! FastMCP Cloud will build and deploy your server, making it available at a URL like `https://your-project.fastmcp.app/mcp`. You can chat with it to test its functionality, or connect to it from any LLM client that supports the MCP protocol. -Log into [FastMCP Cloud](https://www.fastmcp.cloud/login) with your GitHub account, and name your workspace. - -Click "Clone our quickstart." This will create a private repository with a name like, `fastmcp-quickstart-20250804-6o1j` in your GitHub account. - -Then click "Clone & Deploy Server," and that's it! Once the deployment finishes building, it will be ready to access with a client. - -### Connect - -FastMCP Cloud has one-click setup for Claude and Cursor. Navigate to the Connect page and and choose between Claude Code, Claude Desktop, or Cursor. - -## Next Steps - -- Send log messages back to MCP clients with [logging](/servers/logging). -- Open a pull request on the quickstart repository to create a preview deployment in FastMCP Cloud. -- Create a [multi-server client](/clients/client#multi-server-example). -- Mount an MCP server [into your FastAPI app](integrations/fastapi). +For more details, see the [FastMCP Cloud guide](/deployment/fastmcp-cloud). \ No newline at end of file From 4115de6311666565a1562899e579feec3840c1e3 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 3 Sep 2025 07:42:00 -0400 Subject: [PATCH 2/3] Add pre-flight check for bad credentials --- src/fastmcp/client/auth/oauth.py | 78 +++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index b589c2873..1033b80e4 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -4,6 +4,7 @@ import asyncio import json import webbrowser from asyncio import Future +from collections.abc import AsyncGenerator from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Literal @@ -34,6 +35,12 @@ __all__ = ["OAuth"] logger = get_logger(__name__) +class ClientNotFoundError(Exception): + """Raised when OAuth client credentials are not found on the server.""" + + pass + + class StoredToken(BaseModel): """Token storage format with absolute expiry time.""" @@ -300,7 +307,23 @@ class OAuth(OAuthClientProvider): self.context.update_token_expiry(self.context.current_tokens) async def redirect_handler(self, authorization_url: str) -> None: - """Open browser for authorization.""" + """Open browser for authorization, with pre-flight check for invalid client.""" + # Pre-flight check to detect invalid client_id before opening browser + async with httpx.AsyncClient() as client: + response = await client.get(authorization_url, follow_redirects=False) + + # Check for client not found error (400 typically means bad client_id) + if response.status_code == 400: + raise ClientNotFoundError( + "OAuth client not found - cached credentials may be stale" + ) + + # For any non-redirect response, something is wrong + if response.status_code not in (302, 303, 307, 308): + raise RuntimeError( + f"Unexpected authorization response: {response.status_code}" + ) + logger.info(f"OAuth authorization URL: {authorization_url}") webbrowser.open(authorization_url) @@ -336,3 +359,56 @@ class OAuth(OAuthClientProvider): tg.cancel_scope.cancel() raise RuntimeError("OAuth callback handler could not be started") + + async def async_auth_flow( + self, request: httpx.Request + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + """HTTPX auth flow with automatic retry on stale cached credentials. + + If the OAuth flow fails due to invalid/stale client credentials, + clears the cache and retries once with fresh registration. + """ + try: + # First attempt with potentially cached credentials + gen = super().async_auth_flow(request) + response = None + while True: + try: + yielded_request = await gen.asend(response) + response = yield yielded_request + except StopAsyncIteration: + break + + except ClientNotFoundError: + logger.debug( + "OAuth client not found on server, clearing cache and retrying..." + ) + + # Clear cached state and retry once + self._initialized = False + + # Try to clear storage if it supports it + if hasattr(self.context.storage, "clear"): + try: + self.context.storage.clear() + except Exception as e: + logger.warning(f"Failed to clear OAuth storage cache: {e}") + # Can't retry without clearing cache, re-raise original error + raise ClientNotFoundError( + "OAuth client not found and cache could not be cleared" + ) from e + else: + logger.warning( + "Storage does not support clear() - cannot retry with fresh credentials" + ) + # Can't retry without clearing cache, re-raise original error + raise + + gen = super().async_auth_flow(request) + response = None + while True: + try: + yielded_request = await gen.asend(response) + response = yield yielded_request + except StopAsyncIteration: + break From 3977ac447f1e687d55fde93d5b6d2217708d7a7d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 3 Sep 2025 08:29:22 -0400 Subject: [PATCH 3/3] Properly store DCR clients in proxy --- src/fastmcp/client/auth/oauth.py | 2 +- src/fastmcp/server/auth/oauth_proxy.py | 66 +++++--------------------- 2 files changed, 14 insertions(+), 54 deletions(-) diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py index 1033b80e4..c703950d4 100644 --- a/src/fastmcp/client/auth/oauth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -180,7 +180,7 @@ class FileTokenStorage(TokenStorage): for file_type in file_types: path = self._get_file_path(file_type) path.unlink(missing_ok=True) - logger.info(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}") + logger.debug(f"Cleared OAuth cache for {self.get_base_url(self.server_url)}") @classmethod def clear_all(cls, cache_dir: Path | None = None) -> None: diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index f78f7389e..a75f8b690 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -172,7 +172,6 @@ class OAuthProxy(OAuthProvider): 1. Client Registration (DCR): - Accept any client registration request - Store ProxyDCRClient that accepts dynamic redirect URIs - - Return shared upstream credentials to all clients 2. Authorization: - Store transaction mapping client details to proxy flow @@ -323,67 +322,28 @@ class OAuthProxy(OAuthProvider): # ------------------------------------------------------------------------- async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: - """Get client information by ID. + """Get client information by ID. This is generally the random ID + provided to the DCR client during registration, not the upstream client ID. - For unregistered clients, returns a ProxyDCRClient that accepts - any localhost redirect URI for DCR clients. - - Even registered clients use ProxyDCRClient to ensure they can - authenticate with different dynamic ports on reconnection. This - handles the case where a client with cached tokens reconnects - on a different port. + For unregistered clients, returns None (which will raise an error in the SDK). """ client = self._clients.get(client_id) - if client is None: - # For unregistered DCR clients, create a permissive client - # that will accept any localhost redirect URI - # We need at least one URI for Pydantic validation, but our custom - # validate_redirect_uri will accept any localhost URI - client = ProxyDCRClient( - client_id=client_id, - client_secret=None, - redirect_uris=[ - AnyUrl("http://localhost") - ], # Placeholder, validation uses allowed_patterns - grant_types=["authorization_code", "refresh_token"], - scope=self._default_scope_str, - token_endpoint_auth_method="none", - allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, - ) - logger.debug("Created ProxyDCRClient for unregistered client %s", client_id) - return client async def register_client(self, client_info: OAuthClientInformationFull) -> None: - """Register a client locally using fixed upstream credentials. + """Register a client locally - This implementation always uses the upstream client_id and client_secret - regardless of what the client requests. It modifies the client_info object - in place since the MCP framework ignores return values. - - This ensures all clients use the same credentials that are registered - with the upstream server. - - Implementation Detail: - We store a ProxyDCRClient (not the original client_info) to ensure - the client can reconnect with different dynamic redirect URIs. This is - essential for cached token scenarios where the client port changes. - - The flow: - 1. Client provides its desired redirect URIs (dynamic localhost ports) - 2. We create a ProxyDCRClient that will accept ANY localhost URI - 3. We store this flexible client for future authentications - 4. When client reconnects with a different port, ProxyDCRClient accepts it + When a client registers, we create a ProxyDCRClient that is more + forgiving about validating redirect URIs, since the DCR client's + redirect URI will likely be localhost or unknown to the proxied IDP. The + proxied IDP only knows about this server's fixed redirect URI. """ - # Always use the upstream credentials - upstream_id = self._upstream_client_id - upstream_secret = self._upstream_client_secret.get_secret_value() # Create a ProxyDCRClient with configured redirect URI validation proxy_client = ProxyDCRClient( - client_id=upstream_id, - client_secret=upstream_secret, + client_id=client_info.client_id, + client_secret=client_info.client_secret, redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")], grant_types=client_info.grant_types or ["authorization_code", "refresh_token"], @@ -392,8 +352,8 @@ class OAuthProxy(OAuthProvider): allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, ) - # Store the ProxyDCRClient using the upstream ID - self._clients[upstream_id] = proxy_client + # Store the ProxyDCRClient + self._clients[client_info.client_id] = proxy_client # Log redirect URIs to help users discover what patterns they might need if client_info.redirect_uris: @@ -406,7 +366,7 @@ class OAuthProxy(OAuthProvider): logger.debug( "Registered client %s with %d redirect URIs", - upstream_id, + client_info.client_id, len(proxy_client.redirect_uris), )