mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
Merge c8e2eac631 into c8b88b3a37
This commit is contained in:
commit
968fed0a02
7 changed files with 1733 additions and 0 deletions
7
examples/auth/supabase_auth/.env.template
Normal file
7
examples/auth/supabase_auth/.env.template
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
SUPABASE_PROJECT_URL=https://your-project.supabase.co
|
||||
BASE_URL=http://localhost:8000
|
||||
SUPABASE_ANON_KEY=your-anon-key
|
||||
AUTH_TYPE=MAGIC_LINK
|
||||
FASTMCP_LOG_LEVEL=DEBUG
|
||||
MCP_LOG_LEVEL=DEBUG
|
||||
HTTPX_LOG_LEVEL=DEBUG
|
||||
104
examples/auth/supabase_auth/README.md
Normal file
104
examples/auth/supabase_auth/README.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
|
||||
This walks you through getting a full MCP + Supabase Auth setup, using Email Magic Link as the default login method.
|
||||
|
||||
It's been tested on the following MCP Clients:
|
||||
|
||||
1. FastMCP Client
|
||||
|
||||
## Supabase Setup
|
||||
|
||||
### Step 1: Enable OAuth with DCR
|
||||
|
||||
In the Supabase Authentication settings, this setting must be turned on:
|
||||
|
||||
**Enable the Supabase OAuth Server**
|
||||
|
||||
and Dynamic Client Registration (DCR) must also be enabled:
|
||||
|
||||
**Allow Dynamic OAuth Apps**
|
||||
|
||||
Leave **Site URL** and **Authorization Path** as their default values.
|
||||
|
||||
### Step 2: Migrate JWT Keys if needed, and rotate JWT keys
|
||||
|
||||
Under **Project Settings / JWT Keys ** if you see:
|
||||
|
||||
> Right now your project is using the legacy JWT secret.
|
||||
|
||||
You must upgrade to the newer keys, because it will use HS256 (shared secret) which will break the MCP auth handshake. Instead it should use RS256 (asymmetric keys + JWKS).
|
||||
|
||||
If you enable the new type of JWT secrets, you must rotate your keys in order to activate it.
|
||||
|
||||
### Step 3: Enable Supabase Magic Link auth
|
||||
|
||||
Enabled by default, nothing to do here.
|
||||
|
||||
### Step 4: Collect settings needed for env vars
|
||||
|
||||
1. Supabase URL
|
||||
2. Anon key
|
||||
|
||||
See below.
|
||||
|
||||
## Script Setup
|
||||
|
||||
### Pull uv deps
|
||||
|
||||
```
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Setup Env Vars
|
||||
|
||||
Copy .env.template to .env
|
||||
Set your env variables accordingly
|
||||
|
||||
```
|
||||
SUPABASE_PROJECT_URL=https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
You can get these from the Supabase dashboard.
|
||||
|
||||
The rest of the env vars can be left as defaults.
|
||||
|
||||
## How To Test
|
||||
|
||||
First activate the `venv` by running:
|
||||
|
||||
```
|
||||
$ source .venv/bin/activate
|
||||
```
|
||||
|
||||
Then run the following scripts in different terminals.
|
||||
|
||||
### Step 1: Start MCP Server
|
||||
|
||||
```
|
||||
uv run fastmcp run hello_supabase.py --transport http --port 8000
|
||||
```
|
||||
|
||||
### Step 2: Start Consent UI Server
|
||||
|
||||
```
|
||||
uv run uvicorn consent_server:app --port 3000
|
||||
```
|
||||
|
||||
### Step 3: Run FastMCP Client
|
||||
|
||||
```
|
||||
python client.py
|
||||
```
|
||||
|
||||
### Step 4: Click Allow in browser window
|
||||
|
||||
It should open a browser window with Allow / Deny buttons and some debugging information. Hit "Allow".
|
||||
|
||||
### Step 5: Verify that it worked
|
||||
|
||||
1. You will see error in browser window. I am not sure what's going on here.
|
||||
2. In the FastMCP client logs, if it worked you should see: `🎉 Tool result: CallToolResult(content=[TextContent(type='text', text='Hello from Supabase-protected MCP server!', annotations=None, meta=None)], structured_content={'result': 'Hello from Supabase-protected MCP server!'}, meta={'fastmcp': {'wrap_result': True}}, data='Hello from Supabase-protected MCP server!', is_error=False)`
|
||||
|
||||
## Needs Review
|
||||
|
||||
1. Email Auth vs other auth methods as default? (github?)
|
||||
50
examples/auth/supabase_auth/client.py
Normal file
50
examples/auth/supabase_auth/client.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# For testing since MCPJam doesn't support DCR ..
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import webbrowser
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
load_dotenv()
|
||||
|
||||
os.environ["FASTMCP_LOG_LEVEL"] = os.getenv("FASTMCP_LOG_LEVEL", "DEBUG")
|
||||
os.environ["MCP_LOG_LEVEL"] = os.getenv("MCP_LOG_LEVEL", "DEBUG")
|
||||
os.environ["HTTPX_LOG_LEVEL"] = os.getenv("HTTPX_LOG_LEVEL", "DEBUG")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
_original_open = webbrowser.open
|
||||
|
||||
|
||||
def debug_browser_open(url, *args, **kwargs):
|
||||
print("\n================ BROWSER OPEN ================")
|
||||
print(url)
|
||||
print("==============================================\n")
|
||||
return _original_open(url, *args, **kwargs)
|
||||
|
||||
|
||||
webbrowser.open = debug_browser_open
|
||||
|
||||
|
||||
async def main():
|
||||
print("🚀 Starting FastMCP OAuth client")
|
||||
|
||||
async with Client(
|
||||
"http://localhost:8000/mcp",
|
||||
auth="oauth", # 🔥 THIS triggers proper OAuth + DCR
|
||||
) as client:
|
||||
print("✅ Connected to MCP server")
|
||||
|
||||
result = await client.call_tool("hello_supabase", {})
|
||||
print("🎉 Tool result:", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
218
examples/auth/supabase_auth/consent_server.py
Normal file
218
examples/auth/supabase_auth/consent_server.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
SUPABASE_URL = os.environ["SUPABASE_PROJECT_URL"]
|
||||
ANON_KEY = os.environ["SUPABASE_ANON_KEY"]
|
||||
|
||||
# "GITHUB_SIGN_IN" (default, correct for OAuth)
|
||||
# "MAGIC_LINK" (debug only)
|
||||
AUTH_TYPE = os.environ.get("AUTH_TYPE", "GITHUB_SIGN_IN")
|
||||
|
||||
|
||||
@app.get("/oauth/consent", response_class=HTMLResponse)
|
||||
async def consent_page(request: Request):
|
||||
authorization_id = request.query_params.get("authorization_id")
|
||||
|
||||
if not authorization_id:
|
||||
return HTMLResponse("Missing authorization_id", status_code=400)
|
||||
|
||||
return HTMLResponse(f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Consent</title>
|
||||
<style>
|
||||
body {{ font-family: monospace; }}
|
||||
#debug {{ white-space: pre-wrap; background: #111; color: #0f0; padding: 10px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Loading...</h1>
|
||||
<pre id="debug">Starting...</pre>
|
||||
|
||||
<script type="module">
|
||||
import {{ createClient }} from "https://esm.sh/@supabase/supabase-js@2"
|
||||
|
||||
const AUTH_TYPE = "{AUTH_TYPE}"
|
||||
|
||||
const debugEl = document.getElementById("debug")
|
||||
|
||||
function log(...args) {{
|
||||
console.log(...args)
|
||||
debugEl.textContent += "\\n" + args.map(a =>
|
||||
typeof a === "object" ? JSON.stringify(a, null, 2) : a
|
||||
).join(" ")
|
||||
}}
|
||||
|
||||
log("🚀 Consent page loaded")
|
||||
log("🔐 AUTH_TYPE:", AUTH_TYPE)
|
||||
log("🌍 Location:", window.location.href)
|
||||
log("🌐 Origin:", window.location.origin)
|
||||
log("🍪 document.cookie:", document.cookie || "(empty)")
|
||||
|
||||
const supabase = createClient(
|
||||
"{SUPABASE_URL}",
|
||||
"{ANON_KEY}"
|
||||
)
|
||||
|
||||
const authorizationId = "{authorization_id}"
|
||||
|
||||
async function run() {{
|
||||
log("▶️ Starting OAuth consent flow")
|
||||
|
||||
try {{
|
||||
// ---- Session check ----
|
||||
const sessionRes = await supabase.auth.getSession()
|
||||
log("📦 getSession():", sessionRes)
|
||||
|
||||
const userRes = await supabase.auth.getUser()
|
||||
log("👤 getUser():", userRes)
|
||||
|
||||
const user = userRes.data?.user
|
||||
|
||||
if (!user) {{
|
||||
log("⚠️ NO USER SESSION")
|
||||
|
||||
if (AUTH_TYPE === "GITHUB_SIGN_IN") {{
|
||||
log("🔐 Redirecting to GitHub OAuth login...")
|
||||
|
||||
const {{ data, error }} = await supabase.auth.signInWithOAuth({{
|
||||
provider: "github",
|
||||
options: {{
|
||||
redirectTo: window.location.href
|
||||
}}
|
||||
}})
|
||||
|
||||
log("GitHub login result:", {{ data, error }})
|
||||
return
|
||||
}}
|
||||
|
||||
if (AUTH_TYPE === "MAGIC_LINK") {{
|
||||
log("📧 Showing magic link UI")
|
||||
|
||||
document.body.innerHTML = `
|
||||
<h2>No session</h2>
|
||||
<p>Enter email for magic link</p>
|
||||
|
||||
<input id="email" type="email" />
|
||||
<button id="login">Send</button>
|
||||
|
||||
<pre id="debug">${{debugEl.textContent}}</pre>
|
||||
`
|
||||
|
||||
document.getElementById("login").onclick = async () => {{
|
||||
const email = document.getElementById("email").value
|
||||
log("📧 Sending magic link:", email)
|
||||
|
||||
const res = await supabase.auth.signInWithOtp({{
|
||||
email,
|
||||
options: {{
|
||||
emailRedirectTo: window.location.href
|
||||
}}
|
||||
}})
|
||||
|
||||
log("Magic link result:", res)
|
||||
}}
|
||||
|
||||
return
|
||||
}}
|
||||
}}
|
||||
|
||||
log("✅ User authenticated:", user.id)
|
||||
|
||||
// ---- Fetch authorization details ----
|
||||
const authRes =
|
||||
await supabase.auth.oauth.getAuthorizationDetails(authorizationId)
|
||||
|
||||
log("📦 Authorization details:", authRes)
|
||||
|
||||
if (authRes.error) {{
|
||||
log("❌ Authorization error:", authRes.error)
|
||||
|
||||
document.body.innerHTML = `
|
||||
<h2>Error</h2>
|
||||
<pre>${{JSON.stringify(authRes.error, null, 2)}}</pre>
|
||||
<pre id="debug">${{debugEl.textContent}}</pre>
|
||||
`
|
||||
return
|
||||
}}
|
||||
|
||||
const data = authRes.data
|
||||
|
||||
log("✅ Authorization loaded")
|
||||
log("Client:", data.client.name)
|
||||
log("Scopes:", data.scope)
|
||||
|
||||
// ---- Render UI ----
|
||||
document.body.innerHTML = `
|
||||
<h1>Authorize ${{data.client.name}}</h1>
|
||||
<p>Scopes: ${{data.scope}}</p>
|
||||
|
||||
<button id="approve">Approve</button>
|
||||
<button id="deny">Deny</button>
|
||||
|
||||
<pre id="debug">${{debugEl.textContent}}</pre>
|
||||
`
|
||||
|
||||
function logToPage(msg) {{
|
||||
console.log(msg)
|
||||
document.getElementById("debug").textContent += "\\n" + msg
|
||||
}}
|
||||
|
||||
document.getElementById("approve").onclick = async () => {{
|
||||
logToPage("🟢 Approve clicked")
|
||||
|
||||
const res =
|
||||
await supabase.auth.oauth.approveAuthorization(authorizationId)
|
||||
|
||||
log("Approve result:", res)
|
||||
|
||||
if (res.error) {{
|
||||
logToPage("❌ " + JSON.stringify(res.error))
|
||||
return
|
||||
}}
|
||||
|
||||
logToPage("➡️ Redirect → " + res.data.redirect_to)
|
||||
window.location.href = res.data.redirect_to
|
||||
}}
|
||||
|
||||
document.getElementById("deny").onclick = async () => {{
|
||||
logToPage("🔴 Deny clicked")
|
||||
|
||||
const res =
|
||||
await supabase.auth.oauth.denyAuthorization(authorizationId)
|
||||
|
||||
log("Deny result:", res)
|
||||
|
||||
if (res.error) {{
|
||||
logToPage("❌ " + JSON.stringify(res.error))
|
||||
return
|
||||
}}
|
||||
|
||||
logToPage("➡️ Redirect → " + res.data.redirect_to)
|
||||
window.location.href = res.data.redirect_to
|
||||
}}
|
||||
|
||||
}} catch (err) {{
|
||||
log("🔥 FATAL ERROR:", err)
|
||||
|
||||
document.body.innerHTML = `
|
||||
<h2>Fatal error</h2>
|
||||
<pre>${{err}}</pre>
|
||||
<pre id="debug">${{debugEl.textContent}}</pre>
|
||||
`
|
||||
}}
|
||||
}}
|
||||
|
||||
run()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
153
examples/auth/supabase_auth/hello_supabase.py
Normal file
153
examples/auth/supabase_auth/hello_supabase.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.supabase import SupabaseProvider
|
||||
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
||||
from fastmcp.server.middleware.logging import LoggingMiddleware
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
SUPABASE_URL = os.environ["SUPABASE_PROJECT_URL"]
|
||||
BASE_URL = os.environ["BASE_URL"]
|
||||
|
||||
logger.info(f"Initializing SupabaseProvider with project_url: {SUPABASE_URL}")
|
||||
logger.info(f"Server base_url: {BASE_URL}")
|
||||
|
||||
|
||||
class InstrumentedJWTVerifier:
|
||||
def __init__(self, base_verifier, project_url):
|
||||
self.base_verifier = base_verifier
|
||||
self.project_url = project_url
|
||||
|
||||
def _decode_header(self, token: str):
|
||||
try:
|
||||
header_b64 = token.split(".")[0]
|
||||
padding = "=" * (-len(header_b64) % 4)
|
||||
decoded = base64.urlsafe_b64decode(header_b64 + padding)
|
||||
return json.loads(decoded)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def _fetch_jwks(self):
|
||||
url = f"{self.project_url}/auth/v1/.well-known/jwks.json"
|
||||
async with httpx.AsyncClient() as client:
|
||||
res = await client.get(url)
|
||||
return res.json()
|
||||
|
||||
async def verify_token(self, token: str) -> dict:
|
||||
logger.info("🔍 ===== TOKEN DEBUG START =====")
|
||||
|
||||
header = self._decode_header(token)
|
||||
logger.info(f"🧾 JWT HEADER: {header}")
|
||||
|
||||
alg = header.get("alg")
|
||||
kid = header.get("kid")
|
||||
|
||||
logger.info(f"🔑 alg = {alg}")
|
||||
logger.info(f"🆔 kid = {kid}")
|
||||
|
||||
if alg == "HS256":
|
||||
logger.error("🚨 TOKEN IS HS256 → WILL NEVER MATCH JWKS (ROOT CAUSE)")
|
||||
|
||||
jwks = await self._fetch_jwks()
|
||||
jwks_kids = [k.get("kid") for k in jwks.get("keys", [])]
|
||||
|
||||
logger.info(f"📦 JWKS kids: {jwks_kids}")
|
||||
|
||||
if kid not in jwks_kids:
|
||||
logger.error("🚨 KID NOT FOUND IN JWKS → TOKEN CANNOT BE VERIFIED")
|
||||
|
||||
try:
|
||||
payload_part = token.split(".")[1]
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(payload_part + "=" * (-len(payload_part) % 4))
|
||||
)
|
||||
logger.info(
|
||||
f"📦 JWT PAYLOAD (truncated): {dict(list(payload.items())[:5])}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not decode payload: {e}")
|
||||
|
||||
try:
|
||||
result = await self.base_verifier.verify_token(token)
|
||||
logger.info(f"✅ TOKEN VERIFIED: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"❌ TOKEN VERIFICATION FAILED: {e}")
|
||||
raise
|
||||
finally:
|
||||
logger.info("🔍 ===== TOKEN DEBUG END =====\n")
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.base_verifier, name)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(Middleware):
|
||||
async def on_message(self, context: MiddlewareContext, call_next):
|
||||
logger.info(f"📥 INCOMING REQUEST: {context.method} from {context.source}")
|
||||
|
||||
if hasattr(context, "fastmcp_context") and context.fastmcp_context:
|
||||
ctx = context.fastmcp_context
|
||||
if hasattr(ctx, "request_context") and ctx.request_context:
|
||||
headers = getattr(ctx.request_context, "headers", {})
|
||||
auth_header = headers.get("authorization") if headers else None
|
||||
if auth_header:
|
||||
logger.info(
|
||||
f"🔑 AUTH HEADER: {auth_header[:50]}..."
|
||||
if len(auth_header) > 50
|
||||
else f"🔑 AUTH HEADER: {auth_header}"
|
||||
)
|
||||
|
||||
result = await call_next(context)
|
||||
logger.info(f"📤 RESPONSE: {context.method} completed")
|
||||
return result
|
||||
|
||||
|
||||
auth = SupabaseProvider(
|
||||
project_url=SUPABASE_URL,
|
||||
base_url=BASE_URL,
|
||||
)
|
||||
|
||||
auth.token_verifier = InstrumentedJWTVerifier(auth.token_verifier, SUPABASE_URL)
|
||||
|
||||
logger.info("Creating FastMCP server with authentication")
|
||||
|
||||
mcp = FastMCP(
|
||||
"Hello Supabase",
|
||||
auth=auth,
|
||||
middleware=[LoggingMiddleware()],
|
||||
)
|
||||
|
||||
mcp.add_middleware(RequestLoggingMiddleware())
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def hello_supabase() -> str:
|
||||
"""Simple authenticated hello world."""
|
||||
logger.info("🔧 TOOL CALLED: hello_supabase")
|
||||
return "Hello from Supabase-protected MCP server!"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("🚀 Starting FastMCP server on port 3000")
|
||||
logger.info(f"📍 Server will be available at: {BASE_URL}")
|
||||
logger.info("🔐 Authentication endpoints:")
|
||||
logger.info(f" - JWKS: {SUPABASE_URL}/auth/v1/.well-known/jwks.json")
|
||||
logger.info(
|
||||
f" - OAuth Metadata: {BASE_URL}/.well-known/oauth-authorization-server"
|
||||
)
|
||||
mcp.run(transport="http", port=8000)
|
||||
13
examples/auth/supabase_auth/pyproject.toml
Normal file
13
examples/auth/supabase_auth/pyproject.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[project]
|
||||
name = "flasher"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.136.1",
|
||||
"fastmcp>=3.2.4",
|
||||
"httpx>=0.28.1",
|
||||
"python-dotenv>=1.2.2",
|
||||
"uvicorn>=0.46.0",
|
||||
]
|
||||
1188
examples/auth/supabase_auth/uv.lock
generated
Normal file
1188
examples/auth/supabase_auth/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue