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:

  1. The MCP client discovers the server needs auth (a 401 with a WWW-Authenticate challenge) and reads the server’s OAuth metadata.
  2. The client dynamically registers itself (/register) and starts an OAuth flow with PKCE.
  3. The user’s browser opens to GitHub to approve access.
  4. 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), and members_only (allowlisted logins only).
  • A pytest suite that verifies the 401 challenge, the OAuth discovery documents, and the authorization logic — without a real login.
  • A launchd deployment that keeps your OAuth secrets out of the repo.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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

  • .env will hold GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET. Ignoring it first means the secret cannot slip into a commit. You will commit a placeholder .env.example instead.

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

  • fastmcp ships GitHubProvider and 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.

  1. Go to https://github.com/settings/developers → OAuth Apps → New OAuth App.
  2. Set Homepage URL to http://localhost:8000 and Authorization callback URL to http://localhost:8000/auth/callback (this must match your BASE_URL and FastMCP’s default callback path).
  3. 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_URL must be the public URL of your server; GitHub redirects back to BASE_URL/auth/callback, and the OAuth metadata advertises it. For local development, http://localhost:8000 is allowed by GitHub.
  • ALLOWED_LOGINS is your authorization allowlist — empty means “any authenticated GitHub user”; a comma-separated list restricts the privileged tools.
  • Only .env.example is 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_auth constructs the GitHubProvider with your app’s client_id/client_secret, the server’s base_url, and the GitHub scopes to request (read:user is enough to identify the caller). It fails fast with a helpful message if the credentials are missing.
  • GitHubProvider is an OAuth proxy. Passing it as auth makes FastMCP serve /authorize, /token, /register (Dynamic Client Registration), and /auth/callback, and enforce that every tool call carries a valid token.
  • identity pulls the caller’s GitHub login and name out of the token claims (GitHubProvider puts login and the full github_user_data there). It is defensive so it can be unit-tested with a synthetic token.
  • require_member is the authorization gate: with no allowlist it permits any authenticated user; otherwise it raises ToolError unless 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() (from fastmcp.server.dependencies) returns the current request’s token inside a tool; identity turns it into the caller’s GitHub login and name.
  • members_only gates on identity. A logged-in but non-allowlisted user reaches the tool (they are authenticated) and is turned away by require_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.py covers identity extraction and the allowlist gate directly: a synthetic AccessToken stands in for a real GitHub token, so no network or login is needed. It also asserts build_auth fails without credentials.
  • test_oauth_endpoints.py boots the real server with dummy credentials (enough to construct the provider) and drives it through TestClient:
    • the unauthenticated call returns 401 with a Bearer … resource_metadata=… challenge — how a client learns where to authenticate;
    • the authorization-server document advertises /authorize, /token, /register (DCR), the read:user scope, and S256 (PKCE);
    • the protected-resource document names the resource and its authorization server.
  • 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 + export loads your OAuth credentials from .env and makes them available to every recipe — so serve and deploy see them without you exporting anything by hand.
  • serve and deploy guard on GITHUB_CLIENT_ID so a missing .env fails with a clear message instead of a confusing provider error.
  • deploy substitutes the credentials into the plist (Step 8). The rendered plist lives in ~/Library/LaunchAgents/ — outside the repo — so secrets are never committed.
  • login runs 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 that make deploy fills in from your .env. Because the rendered plist lives outside the repo, the secret stays uncommitted.
  • client.py uses Client(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 at BASE_URL.
  • members_only denies you even though you logged in. Your GitHub login is not in ALLOWED_LOGINS. Add it (comma-separated) and restart/redeploy. An empty list allows everyone.
  • Production over plain HTTP. GitHub allows http://localhost for development, but a deployed server must use HTTPS (put it behind a TLS reverse proxy such as Caddy) and set BASE_URL to the public https:// URL.

Recap

You replaced hand-rolled credentials with a real identity provider:

  • GitHubProvider delegates 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 401 challenge and the OAuth discovery documents — plus the authorization logic, without a real login.
  • uv, make, and a launchd plist 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_URL so real clients — and Claude Desktop — can complete the flow.
  • Swap GitHubProvider for WorkOS AuthKitProvider or a generic OAuthProxy/RemoteAuthProvider to 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.