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),
)