Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit a027d75

Browse files
ihrprpraboud-antdsp-antbhosmer-antpcarleton
authoredMay 7, 2025··
Auth SSE simple server example (#610)
Co-authored-by: Peter Raboud <[email protected]> Co-authored-by: David Soria Parra <[email protected]> Co-authored-by: Basil Hosmer <[email protected]> Co-authored-by: Paul Carleton <[email protected]> Co-authored-by: Paul Carleton <[email protected]>
1 parent a1307ab commit a027d75

File tree

7 files changed

+515
-1
lines changed

7 files changed

+515
-1
lines changed
 
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Simple MCP Server with GitHub OAuth Authentication
2+
3+
This is a simple example of an MCP server with GitHub OAuth authentication. It demonstrates the essential components needed for OAuth integration with just a single tool.
4+
5+
This is just an example of a server that uses auth, an official GitHub mcp server is [here](https://github.com/github/github-mcp-server)
6+
7+
## Overview
8+
9+
This simple demo to show to set up a server with:
10+
- GitHub OAuth2 authorization flow
11+
- Single tool: `get_user_profile` to retrieve GitHub user information
12+
13+
14+
## Prerequisites
15+
16+
1. Create a GitHub OAuth App:
17+
- Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App
18+
- Application name: Any name (e.g., "Simple MCP Auth Demo")
19+
- Homepage URL: `http://localhost:8000`
20+
- Authorization callback URL: `http://localhost:8000/github/callback`
21+
- Click "Register application"
22+
- Note down your Client ID and Client Secret
23+
24+
## Required Environment Variables
25+
26+
You MUST set these environment variables before running the server:
27+
28+
```bash
29+
export MCP_GITHUB_GITHUB_CLIENT_ID="your_client_id_here"
30+
export MCP_GITHUB_GITHUB_CLIENT_SECRET="your_client_secret_here"
31+
```
32+
33+
The server will not start without these environment variables properly set.
34+
35+
36+
## Running the Server
37+
38+
```bash
39+
# Set environment variables first (see above)
40+
41+
# Run the server
42+
uv run mcp-simple-auth
43+
```
44+
45+
The server will start on `http://localhost:8000`.
46+
47+
## Available Tool
48+
49+
### get_user_profile
50+
51+
The only tool in this simple example. Returns the authenticated user's GitHub profile information.
52+
53+
**Required scope**: `user`
54+
55+
**Returns**: GitHub user profile data including username, email, bio, etc.
56+
57+
58+
## Troubleshooting
59+
60+
If the server fails to start, check:
61+
1. Environment variables `MCP_GITHUB_GITHUB_CLIENT_ID` and `MCP_GITHUB_GITHUB_CLIENT_SECRET` are set
62+
2. The GitHub OAuth app callback URL matches `http://localhost:8000/github/callback`
63+
3. No other service is using port 8000
64+
65+
You can use [Inspector](https://github.com/modelcontextprotocol/inspector) to test Auth
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Simple MCP server with GitHub OAuth authentication."""
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Main entry point for simple MCP server with GitHub OAuth authentication."""
2+
3+
import sys
4+
5+
from mcp_simple_auth.server import main
6+
7+
sys.exit(main())
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
"""Simple MCP Server with GitHub OAuth Authentication."""
2+
3+
import logging
4+
import secrets
5+
import time
6+
from typing import Any
7+
8+
import click
9+
import httpx
10+
from pydantic import AnyHttpUrl
11+
from pydantic_settings import BaseSettings, SettingsConfigDict
12+
from starlette.exceptions import HTTPException
13+
from starlette.requests import Request
14+
from starlette.responses import JSONResponse, RedirectResponse, Response
15+
16+
from mcp.server.auth.middleware.auth_context import get_access_token
17+
from mcp.server.auth.provider import (
18+
AccessToken,
19+
AuthorizationCode,
20+
AuthorizationParams,
21+
OAuthAuthorizationServerProvider,
22+
RefreshToken,
23+
construct_redirect_uri,
24+
)
25+
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
26+
from mcp.server.fastmcp.server import FastMCP
27+
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
28+
29+
logger = logging.getLogger(__name__)
30+
31+
32+
class ServerSettings(BaseSettings):
33+
"""Settings for the simple GitHub MCP server."""
34+
35+
model_config = SettingsConfigDict(env_prefix="MCP_GITHUB_")
36+
37+
# Server settings
38+
host: str = "localhost"
39+
port: int = 8000
40+
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000")
41+
42+
# GitHub OAuth settings - MUST be provided via environment variables
43+
github_client_id: str # Type: MCP_GITHUB_GITHUB_CLIENT_ID env var
44+
github_client_secret: str # Type: MCP_GITHUB_GITHUB_CLIENT_SECRET env var
45+
github_callback_path: str = "http://localhost:8000/github/callback"
46+
47+
# GitHub OAuth URLs
48+
github_auth_url: str = "https://github.com/login/oauth/authorize"
49+
github_token_url: str = "https://github.com/login/oauth/access_token"
50+
51+
mcp_scope: str = "user"
52+
github_scope: str = "read:user"
53+
54+
def __init__(self, **data):
55+
"""Initialize settings with values from environment variables.
56+
57+
Note: github_client_id and github_client_secret are required but can be
58+
loaded automatically from environment variables (MCP_GITHUB_GITHUB_CLIENT_ID
59+
and MCP_GITHUB_GITHUB_CLIENT_SECRET) and don't need to be passed explicitly.
60+
"""
61+
super().__init__(**data)
62+
63+
64+
class SimpleGitHubOAuthProvider(OAuthAuthorizationServerProvider):
65+
"""Simple GitHub OAuth provider with essential functionality."""
66+
67+
def __init__(self, settings: ServerSettings):
68+
self.settings = settings
69+
self.clients: dict[str, OAuthClientInformationFull] = {}
70+
self.auth_codes: dict[str, AuthorizationCode] = {}
71+
self.tokens: dict[str, AccessToken] = {}
72+
self.state_mapping: dict[str, dict[str, str]] = {}
73+
# Store GitHub tokens with MCP tokens using the format:
74+
# {"mcp_token": "github_token"}
75+
self.token_mapping: dict[str, str] = {}
76+
77+
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
78+
"""Get OAuth client information."""
79+
return self.clients.get(client_id)
80+
81+
async def register_client(self, client_info: OAuthClientInformationFull):
82+
"""Register a new OAuth client."""
83+
self.clients[client_info.client_id] = client_info
84+
85+
async def authorize(
86+
self, client: OAuthClientInformationFull, params: AuthorizationParams
87+
) -> str:
88+
"""Generate an authorization URL for GitHub OAuth flow."""
89+
state = params.state or secrets.token_hex(16)
90+
91+
# Store the state mapping
92+
self.state_mapping[state] = {
93+
"redirect_uri": str(params.redirect_uri),
94+
"code_challenge": params.code_challenge,
95+
"redirect_uri_provided_explicitly": str(
96+
params.redirect_uri_provided_explicitly
97+
),
98+
"client_id": client.client_id,
99+
}
100+
101+
# Build GitHub authorization URL
102+
auth_url = (
103+
f"{self.settings.github_auth_url}"
104+
f"?client_id={self.settings.github_client_id}"
105+
f"&redirect_uri={self.settings.github_callback_path}"
106+
f"&scope={self.settings.github_scope}"
107+
f"&state={state}"
108+
)
109+
110+
return auth_url
111+
112+
async def handle_github_callback(self, code: str, state: str) -> str:
113+
"""Handle GitHub OAuth callback."""
114+
state_data = self.state_mapping.get(state)
115+
if not state_data:
116+
raise HTTPException(400, "Invalid state parameter")
117+
118+
redirect_uri = state_data["redirect_uri"]
119+
code_challenge = state_data["code_challenge"]
120+
redirect_uri_provided_explicitly = (
121+
state_data["redirect_uri_provided_explicitly"] == "True"
122+
)
123+
client_id = state_data["client_id"]
124+
125+
# Exchange code for token with GitHub
126+
async with httpx.AsyncClient() as client:
127+
response = await client.post(
128+
self.settings.github_token_url,
129+
data={
130+
"client_id": self.settings.github_client_id,
131+
"client_secret": self.settings.github_client_secret,
132+
"code": code,
133+
"redirect_uri": self.settings.github_callback_path,
134+
},
135+
headers={"Accept": "application/json"},
136+
)
137+
138+
if response.status_code != 200:
139+
raise HTTPException(400, "Failed to exchange code for token")
140+
141+
data = response.json()
142+
143+
if "error" in data:
144+
raise HTTPException(400, data.get("error_description", data["error"]))
145+
146+
github_token = data["access_token"]
147+
148+
# Create MCP authorization code
149+
new_code = f"mcp_{secrets.token_hex(16)}"
150+
auth_code = AuthorizationCode(
151+
code=new_code,
152+
client_id=client_id,
153+
redirect_uri=AnyHttpUrl(redirect_uri),
154+
redirect_uri_provided_explicitly=redirect_uri_provided_explicitly,
155+
expires_at=time.time() + 300,
156+
scopes=[self.settings.mcp_scope],
157+
code_challenge=code_challenge,
158+
)
159+
self.auth_codes[new_code] = auth_code
160+
161+
# Store GitHub token - we'll map the MCP token to this later
162+
self.tokens[github_token] = AccessToken(
163+
token=github_token,
164+
client_id=client_id,
165+
scopes=[self.settings.github_scope],
166+
expires_at=None,
167+
)
168+
169+
del self.state_mapping[state]
170+
return construct_redirect_uri(redirect_uri, code=new_code, state=state)
171+
172+
async def load_authorization_code(
173+
self, client: OAuthClientInformationFull, authorization_code: str
174+
) -> AuthorizationCode | None:
175+
"""Load an authorization code."""
176+
return self.auth_codes.get(authorization_code)
177+
178+
async def exchange_authorization_code(
179+
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
180+
) -> OAuthToken:
181+
"""Exchange authorization code for tokens."""
182+
if authorization_code.code not in self.auth_codes:
183+
raise ValueError("Invalid authorization code")
184+
185+
# Generate MCP access token
186+
mcp_token = f"mcp_{secrets.token_hex(32)}"
187+
188+
# Store MCP token
189+
self.tokens[mcp_token] = AccessToken(
190+
token=mcp_token,
191+
client_id=client.client_id,
192+
scopes=authorization_code.scopes,
193+
expires_at=int(time.time()) + 3600,
194+
)
195+
196+
# Find GitHub token for this client
197+
github_token = next(
198+
(
199+
token
200+
for token, data in self.tokens.items()
201+
# see https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/
202+
# which you get depends on your GH app setup.
203+
if (token.startswith("ghu_") or token.startswith("gho_"))
204+
and data.client_id == client.client_id
205+
),
206+
None,
207+
)
208+
209+
# Store mapping between MCP token and GitHub token
210+
if github_token:
211+
self.token_mapping[mcp_token] = github_token
212+
213+
del self.auth_codes[authorization_code.code]
214+
215+
return OAuthToken(
216+
access_token=mcp_token,
217+
token_type="bearer",
218+
expires_in=3600,
219+
scope=" ".join(authorization_code.scopes),
220+
)
221+
222+
async def load_access_token(self, token: str) -> AccessToken | None:
223+
"""Load and validate an access token."""
224+
access_token = self.tokens.get(token)
225+
if not access_token:
226+
return None
227+
228+
# Check if expired
229+
if access_token.expires_at and access_token.expires_at < time.time():
230+
del self.tokens[token]
231+
return None
232+
233+
return access_token
234+
235+
async def load_refresh_token(
236+
self, client: OAuthClientInformationFull, refresh_token: str
237+
) -> RefreshToken | None:
238+
"""Load a refresh token - not supported."""
239+
return None
240+
241+
async def exchange_refresh_token(
242+
self,
243+
client: OAuthClientInformationFull,
244+
refresh_token: RefreshToken,
245+
scopes: list[str],
246+
) -> OAuthToken:
247+
"""Exchange refresh token"""
248+
raise NotImplementedError("Not supported")
249+
250+
async def revoke_token(
251+
self, token: str, token_type_hint: str | None = None
252+
) -> None:
253+
"""Revoke a token."""
254+
if token in self.tokens:
255+
del self.tokens[token]
256+
257+
258+
def create_simple_mcp_server(settings: ServerSettings) -> FastMCP:
259+
"""Create a simple FastMCP server with GitHub OAuth."""
260+
oauth_provider = SimpleGitHubOAuthProvider(settings)
261+
262+
auth_settings = AuthSettings(
263+
issuer_url=settings.server_url,
264+
client_registration_options=ClientRegistrationOptions(
265+
enabled=True,
266+
valid_scopes=[settings.mcp_scope],
267+
default_scopes=[settings.mcp_scope],
268+
),
269+
required_scopes=[settings.mcp_scope],
270+
)
271+
272+
app = FastMCP(
273+
name="Simple GitHub MCP Server",
274+
instructions="A simple MCP server with GitHub OAuth authentication",
275+
auth_server_provider=oauth_provider,
276+
host=settings.host,
277+
port=settings.port,
278+
debug=True,
279+
auth=auth_settings,
280+
)
281+
282+
@app.custom_route("/github/callback", methods=["GET"])
283+
async def github_callback_handler(request: Request) -> Response:
284+
"""Handle GitHub OAuth callback."""
285+
code = request.query_params.get("code")
286+
state = request.query_params.get("state")
287+
288+
if not code or not state:
289+
raise HTTPException(400, "Missing code or state parameter")
290+
291+
try:
292+
redirect_uri = await oauth_provider.handle_github_callback(code, state)
293+
return RedirectResponse(status_code=302, url=redirect_uri)
294+
except HTTPException:
295+
raise
296+
except Exception as e:
297+
logger.error("Unexpected error", exc_info=e)
298+
return JSONResponse(
299+
status_code=500,
300+
content={
301+
"error": "server_error",
302+
"error_description": "Unexpected error",
303+
},
304+
)
305+
306+
def get_github_token() -> str:
307+
"""Get the GitHub token for the authenticated user."""
308+
access_token = get_access_token()
309+
if not access_token:
310+
raise ValueError("Not authenticated")
311+
312+
# Get GitHub token from mapping
313+
github_token = oauth_provider.token_mapping.get(access_token.token)
314+
315+
if not github_token:
316+
raise ValueError("No GitHub token found for user")
317+
318+
return github_token
319+
320+
@app.tool()
321+
async def get_user_profile() -> dict[str, Any]:
322+
"""Get the authenticated user's GitHub profile information.
323+
324+
This is the only tool in our simple example. It requires the 'user' scope.
325+
"""
326+
github_token = get_github_token()
327+
328+
async with httpx.AsyncClient() as client:
329+
response = await client.get(
330+
"https://api.github.com/user",
331+
headers={
332+
"Authorization": f"Bearer {github_token}",
333+
"Accept": "application/vnd.github.v3+json",
334+
},
335+
)
336+
337+
if response.status_code != 200:
338+
raise ValueError(
339+
f"GitHub API error: {response.status_code} - {response.text}"
340+
)
341+
342+
return response.json()
343+
344+
return app
345+
346+
347+
@click.command()
348+
@click.option("--port", default=8000, help="Port to listen on")
349+
@click.option("--host", default="localhost", help="Host to bind to")
350+
def main(port: int, host: str) -> int:
351+
"""Run the simple GitHub MCP server."""
352+
logging.basicConfig(level=logging.INFO)
353+
354+
try:
355+
# No hardcoded credentials - all from environment variables
356+
settings = ServerSettings(host=host, port=port)
357+
except ValueError as e:
358+
logger.error(
359+
"Failed to load settings. Make sure environment variables are set:"
360+
)
361+
logger.error(" MCP_GITHUB_GITHUB_CLIENT_ID=<your-client-id>")
362+
logger.error(" MCP_GITHUB_GITHUB_CLIENT_SECRET=<your-client-secret>")
363+
logger.error(f"Error: {e}")
364+
return 1
365+
366+
mcp_server = create_simple_mcp_server(settings)
367+
mcp_server.run(transport="sse")
368+
return 0
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
[project]
2+
name = "mcp-simple-auth"
3+
version = "0.1.0"
4+
description = "A simple MCP server demonstrating OAuth authentication"
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
authors = [{ name = "Anthropic, PBC." }]
8+
license = { text = "MIT" }
9+
dependencies = [
10+
"anyio>=4.5",
11+
"click>=8.1.0",
12+
"httpx>=0.27",
13+
"mcp",
14+
"pydantic>=2.0",
15+
"pydantic-settings>=2.5.2",
16+
"sse-starlette>=1.6.1",
17+
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
18+
]
19+
20+
[project.scripts]
21+
mcp-simple-auth = "mcp_simple_auth.server:main"
22+
23+
[build-system]
24+
requires = ["hatchling"]
25+
build-backend = "hatchling.build"
26+
27+
[tool.hatch.build.targets.wheel]
28+
packages = ["mcp_simple_auth"]
29+
30+
[tool.uv]
31+
dev-dependencies = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"]

‎src/mcp/server/auth/routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def build_metadata(
177177
issuer=issuer_url,
178178
authorization_endpoint=authorization_url,
179179
token_endpoint=token_url,
180-
scopes_supported=None,
180+
scopes_supported=client_registration_options.valid_scopes,
181181
response_types_supported=["code"],
182182
response_modes_supported=None,
183183
grant_types_supported=["authorization_code", "refresh_token"],

‎uv.lock

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.