In Gate a FastMCP Server to Paying Customers you minted your own license keys
and verified them yourself. That works, but it means you own the hard parts:
issuing credentials, storing them, revoking them. This tutorial takes the other
approach: delegate authentication to a real identity provider, GitHub,
using FastMCP’s GitHubProvider. Your server never sees
a password; users log in with GitHub, and the MCP client handles the whole OAuth
2.0 dance (Dynamic Client Registration, the browser login, PKCE, token exchange).
You will then add a thin authorization layer on top: an allowlist of GitHub
logins, so only specific accounts can call your privileged tools — the identity
version of the paid-customer gate. The stack is Mac-native: uv, make, and a
launchd service.
Authentication vs authorization. GitHub tells you who the caller is (authentication). Whether that person may use a tool is your decision (authorization) — here, an allowlist keyed on their GitHub login.
How the flow works
GitHubProvider runs your server as an OAuth proxy. It serves the standard
discovery and OAuth endpoints (/authorize, /token, /register,
/auth/callback) and brokers logins to GitHub:
- The MCP client discovers the server needs auth (a
401with aWWW-Authenticatechallenge) and reads the server’s OAuth metadata. - The client dynamically registers itself (
/register) and starts an OAuth flow with PKCE. - The user’s browser opens to GitHub to approve access.
- GitHub redirects back to
/auth/callback; the server exchanges the code for a token and issues the client a token to call tools with.
FastMCP’s client does steps 1–4 for you with a single auth="oauth" argument.
What you will build
- A server authenticated by GitHub via
GitHubProvider. - Tools:
whoami(the caller’s GitHub identity),word_count(any authenticated user), andmembers_only(allowlisted logins only). - A
pytestsuite that verifies the401challenge, the OAuth discovery documents, and the authorization logic — without a real login. - A
launchddeployment that keeps your OAuth secrets out of the repo.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - A GitHub account, to create an OAuth App (free) in Step 3.
- Familiarity with OAuth concepts is helpful; the moving parts are explained as they appear.
Everything runs through uv and make. OAuth applies to the HTTP transport.
Step 1: Scaffold and lock down hygiene
Create the workspace and a .gitignore first. It ignores .env, which will hold
your OAuth client secret — a credential that must never be committed.
Create the files
mkdir -p oauth-fastmcp-server-macos
cd oauth-fastmcp-server-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
logs/
*.log
*.pid
# Secrets — never commit OAuth client credentials
.env
# OS / editor noise
.DS_Store
Detailed breakdown
.envwill holdGITHUB_CLIENT_IDandGITHUB_CLIENT_SECRET. Ignoring it first means the secret cannot slip into a commit. You will commit a placeholder.env.exampleinstead.
Step 2: Initialize the project with uv
Create the uv project and add FastMCP plus the test tools.
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "A FastMCP server authenticated with GitHub OAuth"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.3",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
fastmcpshipsGitHubProviderand the OAuth machinery; there is nothing else to install for auth.
Step 3: Create a GitHub OAuth App and configure the server
Register an OAuth App with GitHub, then store its credentials in .env.
- Go to https://github.com/settings/developers → OAuth Apps → New OAuth App.
- Set Homepage URL to
http://localhost:8000and Authorization callback URL tohttp://localhost:8000/auth/callback(this must match yourBASE_URLand FastMCP’s default callback path). - Copy the Client ID, generate a Client secret, and put both in
.env.
Commit a non-secret template so others know what to fill in.
Create the files
touch .env.example
cp .env.example .env # then edit .env with real values
Add the code: .env.example
# Copy to .env and fill in from your GitHub OAuth App
# (https://github.com/settings/developers). .env is gitignored.
GITHUB_CLIENT_ID=your_client_id
GITHUB_CLIENT_SECRET=your_client_secret
BASE_URL=http://localhost:8000
# Optional: comma-separated GitHub logins allowed to call members-only tools.
# Leave empty to allow any authenticated user.
ALLOWED_LOGINS=
Detailed breakdown
BASE_URLmust be the public URL of your server; GitHub redirects back toBASE_URL/auth/callback, and the OAuth metadata advertises it. For local development,http://localhost:8000is allowed by GitHub.ALLOWED_LOGINSis your authorization allowlist — empty means “any authenticated GitHub user”; a comma-separated list restricts the privileged tools.- Only
.env.exampleis committed; the real.env(with your secret) is git-ignored.
Step 4: Configure OAuth and authorization
Isolate auth in one module: build the GitHub provider from the environment, and provide identity extraction plus the allowlist gate.
Create the file
touch auth.py
Add the code: auth.py
"""OAuth configuration and identity-based authorization.
Authentication is delegated to GitHub via FastMCP's GitHubProvider, which acts
as an OAuth proxy: the MCP client performs a standard OAuth 2.0 flow (with
Dynamic Client Registration and PKCE) against this server, which brokers the
login to GitHub. Once authenticated, the caller's GitHub identity lands in the
access token's claims.
Authorization is separate: an optional allowlist of GitHub logins gates the
"members only" tools, so you can restrict the server to specific accounts (for
example, paying customers identified by their GitHub username).
"""
import os
from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AccessToken
from fastmcp.server.auth.providers.github import GitHubProvider
def build_auth() -> GitHubProvider:
"""Construct the GitHub OAuth provider from environment configuration."""
client_id = os.environ.get("GITHUB_CLIENT_ID")
client_secret = os.environ.get("GITHUB_CLIENT_SECRET")
if not client_id or not client_secret:
raise RuntimeError(
"Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET (see .env.example). "
"Create a GitHub OAuth App at https://github.com/settings/developers."
)
return GitHubProvider(
client_id=client_id,
client_secret=client_secret,
base_url=os.environ.get("BASE_URL", "http://localhost:8000"),
required_scopes=["read:user"],
)
def allowed_logins() -> set[str]:
"""GitHub logins permitted to call members-only tools (empty = allow all)."""
raw = os.environ.get("ALLOWED_LOGINS", "")
return {name.strip().lower() for name in raw.split(",") if name.strip()}
def identity(access: AccessToken | None) -> dict:
"""Extract a small identity dict from a GitHub access token."""
claims = getattr(access, "claims", None) or {}
user = claims.get("github_user_data") or {}
return {
"login": claims.get("login"),
"name": user.get("name"),
"scopes": getattr(access, "scopes", []),
}
def require_member(access: AccessToken | None) -> None:
"""Raise ToolError unless the caller's GitHub login is allowed."""
allow = allowed_logins()
if not allow: # no allowlist configured -> any authenticated user is fine
return
login = (identity(access).get("login") or "").lower()
if login not in allow:
raise ToolError(
f"'{login or 'unknown'}' is not authorized for this tool. "
"Ask an admin to add your GitHub login to ALLOWED_LOGINS."
)
Detailed breakdown
build_authconstructs theGitHubProviderwith your app’sclient_id/client_secret, the server’sbase_url, and the GitHub scopes to request (read:useris enough to identify the caller). It fails fast with a helpful message if the credentials are missing.GitHubProvideris an OAuth proxy. Passing it asauthmakes FastMCP serve/authorize,/token,/register(Dynamic Client Registration), and/auth/callback, and enforce that every tool call carries a valid token.identitypulls the caller’s GitHubloginandnameout of the token claims (GitHubProviderputsloginand the fullgithub_user_datathere). It is defensive so it can be unit-tested with a synthetic token.require_memberis the authorization gate: with no allowlist it permits any authenticated user; otherwise it raisesToolErrorunless the caller’s login is listed (case-insensitive). Keeping it a pure function makes it trivial to test.
Step 5: Write the server
The tools read the authenticated identity via get_access_token(). whoami and
word_count accept any GitHub user; members_only calls the allowlist gate.
Create the file
touch server.py
Add the code: server.py
"""A FastMCP server authenticated with GitHub OAuth.
Every request must present a token obtained by logging in with GitHub. Tools
read the caller's GitHub identity from the access token; "members only" tools
additionally require the login to be on an allowlist. OAuth applies to the HTTP
transport, which is what you deploy.
"""
import os
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_access_token
from auth import build_auth, identity, require_member
mcp = FastMCP("macmcp", auth=build_auth())
@mcp.tool
def whoami() -> dict:
"""Return the authenticated caller's GitHub identity."""
return identity(get_access_token())
@mcp.tool
def word_count(text: str) -> int:
"""Count words in text. Available to any authenticated GitHub user."""
return len(text.split())
@mcp.tool
def members_only(secret_name: str) -> dict:
"""A tool restricted to allowlisted GitHub logins."""
require_member(get_access_token())
who = identity(get_access_token())
return {"granted_to": who["login"], "secret": f"the value of {secret_name}"}
def main() -> None:
if os.environ.get("MCP_TRANSPORT", "stdio").lower() == "http":
mcp.run(
transport="http",
host=os.environ.get("MCP_HOST", "127.0.0.1"),
port=int(os.environ.get("MCP_PORT", "8000")),
)
else:
mcp.run() # stdio (unauthenticated; local dev only)
if __name__ == "__main__":
main()
Detailed breakdown
FastMCP("macmcp", auth=build_auth())wires GitHub OAuth into every HTTP request. Unauthenticated calls are rejected before any tool runs.get_access_token()(fromfastmcp.server.dependencies) returns the current request’s token inside a tool;identityturns it into the caller’s GitHub login and name.members_onlygates on identity. A logged-in but non-allowlisted user reaches the tool (they are authenticated) and is turned away byrequire_member— authentication and authorization doing distinct jobs.- The transport switch keeps stdio for unauthenticated local dev and HTTP for the deployed, OAuth-protected service.
Step 6: Test the challenge, discovery, and authorization
You can verify almost everything without a real login: the 401 challenge and
the OAuth discovery documents (via Starlette’s TestClient with dummy
credentials), and the authorization logic (as pure functions). The one thing you
cannot automate is the interactive GitHub consent — that is exercised by hand in
Step 8.
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_auth.py tests/test_oauth_endpoints.py pytest.ini
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
Add the code: tests/test_auth.py
"""Test the identity extraction and allowlist authorization (pure logic)."""
import pytest
from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AccessToken
import auth
def _token(login: str) -> AccessToken:
return AccessToken(
token="t",
client_id="c",
scopes=["read:user"],
claims={"login": login, "github_user_data": {"name": f"{login} name"}},
)
def test_identity_extracts_login_and_name():
ident = auth.identity(_token("octocat"))
assert ident["login"] == "octocat"
assert ident["name"] == "octocat name"
def test_identity_handles_missing_token():
assert auth.identity(None)["login"] is None
def test_no_allowlist_allows_anyone(monkeypatch):
monkeypatch.delenv("ALLOWED_LOGINS", raising=False)
auth.require_member(_token("stranger")) # should not raise
def test_allowlisted_login_is_permitted(monkeypatch):
monkeypatch.setenv("ALLOWED_LOGINS", "octocat, hubot")
auth.require_member(_token("Octocat")) # case-insensitive; no raise
def test_non_allowlisted_login_is_denied(monkeypatch):
monkeypatch.setenv("ALLOWED_LOGINS", "octocat")
with pytest.raises(ToolError):
auth.require_member(_token("stranger"))
def test_build_auth_requires_credentials(monkeypatch):
monkeypatch.delenv("GITHUB_CLIENT_ID", raising=False)
monkeypatch.delenv("GITHUB_CLIENT_SECRET", raising=False)
with pytest.raises(RuntimeError):
auth.build_auth()
Add the code: tests/test_oauth_endpoints.py
"""Test the OAuth server behavior with Starlette's TestClient and dummy creds.
These assert the protocol surface every MCP OAuth client relies on — the 401
challenge and the discovery documents — without performing a real GitHub login.
"""
import importlib
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
monkeypatch.setenv("GITHUB_CLIENT_ID", "dummy-id")
monkeypatch.setenv("GITHUB_CLIENT_SECRET", "dummy-secret")
monkeypatch.setenv("BASE_URL", "http://localhost:8000")
import server
importlib.reload(server)
with TestClient(server.mcp.http_app()) as c:
yield c
def test_unauthenticated_call_gets_401_challenge(client):
resp = client.post(
"/mcp/",
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
headers={"Accept": "application/json, text/event-stream"},
)
assert resp.status_code == 401
challenge = resp.headers.get("www-authenticate", "")
assert challenge.startswith("Bearer")
assert "resource_metadata=" in challenge
def test_authorization_server_metadata(client):
meta = client.get("/.well-known/oauth-authorization-server").json()
assert meta["authorization_endpoint"].endswith("/authorize")
assert meta["token_endpoint"].endswith("/token")
# Dynamic Client Registration endpoint (clients self-register).
assert meta["registration_endpoint"].endswith("/register")
assert "read:user" in meta["scopes_supported"]
assert "S256" in meta["code_challenge_methods_supported"] # PKCE
def test_protected_resource_metadata(client):
meta = client.get("/.well-known/oauth-protected-resource/mcp").json()
assert meta["resource"].endswith("/mcp")
assert meta["authorization_servers"]
assert "read:user" in meta["scopes_supported"]
Detailed breakdown
test_auth.pycovers identity extraction and the allowlist gate directly: a syntheticAccessTokenstands in for a real GitHub token, so no network or login is needed. It also assertsbuild_authfails without credentials.test_oauth_endpoints.pyboots the real server with dummy credentials (enough to construct the provider) and drives it throughTestClient:- the unauthenticated call returns
401with aBearer … resource_metadata=…challenge — how a client learns where to authenticate; - the authorization-server document advertises
/authorize,/token,/register(DCR), theread:userscope, andS256(PKCE); - the protected-resource document names the resource and its authorization server.
- the unauthenticated call returns
- These are exactly the protocol guarantees an MCP OAuth client depends on, so passing them means the flow will work for a real client.
Run them:
uv run pytest -v
All tests pass, with no GitHub App required.
Step 7: The Makefile
Wrap development and deployment, loading secrets from .env. Plain make prints
help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# Load local secrets from .env (gitignored) and export them to recipes.
-include .env
export
# --- Deploy configuration (launchd user agent) --------------------------------
LABEL := com.macmcp.oauth
PLIST := $(HOME)/Library/LaunchAgents/$(LABEL).plist
UV := $(shell command -v uv)
DIR := $(shell pwd)
DOMAIN := gui/$(shell id -u)
BASE_URL ?= http://localhost:8000
.PHONY: help install test serve login deploy undeploy status logs clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
test: ## Run the test suite (uses dummy credentials, no real login)
uv run pytest -v
serve: ## Run the HTTP server in the foreground (needs GITHUB_CLIENT_ID/SECRET)
@test -n "$(GITHUB_CLIENT_ID)" || (echo "Set GITHUB_CLIENT_ID/SECRET in .env (see .env.example)" && exit 1)
MCP_TRANSPORT=http uv run python server.py
login: ## Connect a client via GitHub OAuth (opens a browser)
uv run python scripts/client.py
deploy: ## Render the launchd plist (with your creds) and start the service
@test -n "$(GITHUB_CLIENT_ID)" || (echo "Set GITHUB_CLIENT_ID/SECRET in .env first" && exit 1)
@mkdir -p logs
@sed -e 's#@UV@#$(UV)#g' \
-e 's#@DIR@#$(DIR)#g' \
-e 's#@BASE_URL@#$(BASE_URL)#g' \
-e 's#@GITHUB_CLIENT_ID@#$(GITHUB_CLIENT_ID)#g' \
-e 's#@GITHUB_CLIENT_SECRET@#$(GITHUB_CLIENT_SECRET)#g' \
-e 's#@ALLOWED_LOGINS@#$(ALLOWED_LOGINS)#g' \
-e 's#@PATH@#$(dir $(UV)):/usr/bin:/bin#g' \
deploy/$(LABEL).plist.template > $(PLIST)
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
launchctl bootstrap $(DOMAIN) $(PLIST)
@echo "Deployed $(LABEL). The plist contains secrets and lives outside the repo."
undeploy: ## Stop the service and remove the launchd plist
launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
rm -f $(PLIST)
@echo "Removed $(LABEL)."
status: ## Show whether the service is registered and running
@launchctl print $(DOMAIN)/$(LABEL) 2>/dev/null \
| grep -E 'state =|pid =' || echo "$(LABEL) is not loaded."
logs: ## Tail the service log files
@tail -n 40 -f logs/server.out.log logs/server.err.log
clean: ## Remove caches and logs
rm -rf .pytest_cache logs __pycache__ tests/__pycache__
Detailed breakdown
-include .env+exportloads your OAuth credentials from.envand makes them available to every recipe — soserveanddeploysee them without you exporting anything by hand.serveanddeployguard onGITHUB_CLIENT_IDso a missing.envfails with a clear message instead of a confusing provider error.deploysubstitutes the credentials into the plist (Step 8). The rendered plist lives in~/Library/LaunchAgents/— outside the repo — so secrets are never committed.loginruns the interactive client (Step 8).
Step 8: Deploy and log in for real
Add the launchd plist template and the OAuth client, then run the real flow.
Create the files
mkdir -p deploy scripts
touch deploy/com.macmcp.oauth.plist.template scripts/client.py
Add the code: deploy/com.macmcp.oauth.plist.template
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.macmcp.oauth</string>
<key>ProgramArguments</key>
<array>
<string>@UV@</string>
<string>run</string>
<string>python</string>
<string>server.py</string>
</array>
<key>WorkingDirectory</key>
<string>@DIR@</string>
<key>EnvironmentVariables</key>
<dict>
<key>MCP_TRANSPORT</key>
<string>http</string>
<key>MCP_HOST</key>
<string>127.0.0.1</string>
<key>MCP_PORT</key>
<string>8000</string>
<key>BASE_URL</key>
<string>@BASE_URL@</string>
<key>GITHUB_CLIENT_ID</key>
<string>@GITHUB_CLIENT_ID@</string>
<key>GITHUB_CLIENT_SECRET</key>
<string>@GITHUB_CLIENT_SECRET@</string>
<key>ALLOWED_LOGINS</key>
<string>@ALLOWED_LOGINS@</string>
<key>PATH</key>
<string>@PATH@</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>@DIR@/logs/server.out.log</string>
<key>StandardErrorPath</key>
<string>@DIR@/logs/server.err.log</string>
</dict>
</plist>
Add the code: scripts/client.py
"""Connect to the server with OAuth and call a tool.
Running this opens your browser to log in with GitHub, then calls whoami. It
requires a running server (see `make serve`) and cannot run headless.
"""
import asyncio
import os
from fastmcp import Client
URL = os.environ.get("MCP_URL", "http://127.0.0.1:8000/mcp/")
async def main() -> None:
# auth="oauth" drives the full flow: dynamic client registration, the browser
# login, PKCE, and token exchange — all handled by the FastMCP client.
async with Client(URL, auth="oauth") as client:
me = (await client.call_tool("whoami", {})).data
print("logged in as:", me["login"], f"({me['name']})")
print("scopes:", me["scopes"])
wc = await client.call_tool("word_count", {"text": "authenticated with github"})
print("word_count ->", wc.data)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
- The plist injects the OAuth env (
GITHUB_CLIENT_ID/SECRET,BASE_URL,ALLOWED_LOGINS) from placeholders thatmake deployfills in from your.env. Because the rendered plist lives outside the repo, the secret stays uncommitted. client.pyusesClient(URL, auth="oauth")— that single argument makes the FastMCP client perform Dynamic Client Registration, open the browser to GitHub, complete PKCE, and attach the resulting token to every call.
Run it end to end:
# Terminal 1 — start the server (foreground, reads .env)
make serve
# Terminal 2 — log in and call tools (opens a browser to GitHub)
make login
# logged in as: your-login (Your Name)
# scopes: ['read:user']
# word_count -> 3
Or run it supervised under launchd:
make deploy
make status # state = running
make undeploy
To lock the privileged tool to specific people, set ALLOWED_LOGINS in .env
(e.g. ALLOWED_LOGINS=octocat,hubot) and redeploy; members_only then rejects
everyone else with a ToolError.
Troubleshooting
Set GITHUB_CLIENT_ID/SECRET in .env. You have not created.env(copy.env.example) or filled in your OAuth App credentials.- GitHub says “redirect_uri mismatch”. The callback URL in your OAuth App must
be exactly
BASE_URL/auth/callback(e.g.http://localhost:8000/auth/callback). - The browser never opens on
make login. The client needs a running server (make serve) and a desktop session; it cannot complete OAuth headless. Confirm the server is up atBASE_URL. members_onlydenies you even though you logged in. Your GitHub login is not inALLOWED_LOGINS. Add it (comma-separated) and restart/redeploy. An empty list allows everyone.- Production over plain HTTP. GitHub allows
http://localhostfor development, but a deployed server must use HTTPS (put it behind a TLS reverse proxy such as Caddy) and setBASE_URLto the publichttps://URL.
Recap
You replaced hand-rolled credentials with a real identity provider:
GitHubProviderdelegates authentication to GitHub as an OAuth proxy — Dynamic Client Registration, browser login, and PKCE — so your server never handles passwords.- An allowlist of GitHub logins provides authorization on top of authentication, the identity equivalent of a paid-customer gate.
- The tests verify the protocol surface — the
401challenge and the OAuth discovery documents — plus the authorization logic, without a real login. uv,make, and alaunchdplist deploy it while keeping the client secret out of the repo.
Next improvements
- Put the server behind HTTPS (Caddy/nginx) and set a public
BASE_URLso real clients — and Claude Desktop — can complete the flow. - Swap
GitHubProviderfor WorkOSAuthKitProvideror a genericOAuthProxy/RemoteAuthProviderto support Google, Microsoft, or your own IdP. - Authorize on GitHub org/team membership (via an API call in the gate) instead of a static login allowlist.
- Combine with the license article: map a GitHub identity to a subscription record to grant plan-based scopes.