|
| 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 |
0 commit comments