-
Notifications
You must be signed in to change notification settings - Fork 2k
Add support for remote-oauth-support Fix #686 #764
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
# Simple MCP Server with GitHub OAuth Authentication | ||
|
||
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. | ||
|
||
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) | ||
|
||
## Overview | ||
|
||
This simple demo to show to set up a server with: | ||
- GitHub OAuth2 authorization flow | ||
- Single tool: `get_user_profile` to retrieve GitHub user information | ||
|
||
|
||
## Prerequisites | ||
|
||
1. Create a GitHub OAuth App: | ||
- Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App | ||
- Application name: Any name (e.g., "Simple MCP Auth Demo") | ||
- Homepage URL: `http://localhost:8000` | ||
- Authorization callback URL: `http://localhost:8000/github/callback` | ||
- Click "Register application" | ||
- Note down your Client ID and Client Secret | ||
|
||
## Required Environment Variables | ||
|
||
You MUST set these environment variables before running the server: | ||
|
||
```bash | ||
export MCP_GITHUB_GITHUB_CLIENT_ID="your_client_id_here" | ||
export MCP_GITHUB_GITHUB_CLIENT_SECRET="your_client_secret_here" | ||
``` | ||
|
||
The server will not start without these environment variables properly set. | ||
|
||
|
||
## Running the Server | ||
|
||
```bash | ||
# Set environment variables first (see above) | ||
|
||
# Run the server | ||
uv run mcp-simple-auth | ||
``` | ||
|
||
The server will start on `http://localhost:8000`. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd make it more explicit that it starts on |
||
|
||
### Transport Options | ||
|
||
This server supports multiple transport protocols that can run on the same port: | ||
|
||
#### SSE (Server-Sent Events) - Default | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we switch the default to streamable http / move sse down? SSE is now deprecated Also, seems that connecting to the SSE server doesn't trigger authentication the way it does to the Streamable HTTP server. |
||
```bash | ||
uv run mcp-simple-auth | ||
# or explicitly: | ||
uv run mcp-simple-auth --transport sse | ||
``` | ||
|
||
SSE transport provides endpoint: | ||
- `/sse` | ||
|
||
#### Streamable HTTP | ||
```bash | ||
uv run mcp-simple-auth --transport streamable-http | ||
``` | ||
|
||
Streamable HTTP transport provides endpoint: | ||
- `/mcp` | ||
|
||
|
||
This ensures backward compatibility without needing multiple server instances. When using SSE transport (`--transport sse`), only the `/sse` endpoint is available. | ||
|
||
## Available Tool | ||
|
||
### get_user_profile | ||
|
||
The only tool in this simple example. Returns the authenticated user's GitHub profile information. | ||
|
||
**Required scope**: `user` | ||
|
||
**Returns**: GitHub user profile data including username, email, bio, etc. | ||
|
||
|
||
## Troubleshooting | ||
|
||
If the server fails to start, check: | ||
1. Environment variables `MCP_GITHUB_GITHUB_CLIENT_ID` and `MCP_GITHUB_GITHUB_CLIENT_SECRET` are set | ||
2. The GitHub OAuth app callback URL matches `http://localhost:8000/github/callback` | ||
3. No other service is using port 8000 | ||
4. The transport specified is valid (`sse` or `streamable-http`) | ||
|
||
You can use [Inspector](https://github.com/modelcontextprotocol/inspector) to test Auth | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have you found an Inspector workflow that works for you? Even after applying the couple of fixes I suggested, I hit a wall w/ the AS not setting CORS headers that would allow localhost to proceed. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
"""Simple MCP server with GitHub OAuth authentication.""" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
"""Main entry point for simple MCP server with GitHub OAuth authentication.""" | ||
|
||
import sys | ||
|
||
from mcp_simple_remote_auth.server import main | ||
|
||
sys.exit(main()) # type: ignore[call-arg] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,210 @@ | ||
"""Simple MCP Server with GitHub OAuth Authentication.""" | ||
|
||
import logging | ||
from typing import Any, Literal | ||
|
||
import click | ||
import jwt | ||
import requests | ||
from pydantic import AnyHttpUrl | ||
from pydantic_settings import BaseSettings, SettingsConfigDict | ||
|
||
from mcp.server.auth.provider import ( | ||
AccessToken, | ||
TokenValidator, | ||
) | ||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions | ||
from mcp.server.fastmcp.server import FastMCP | ||
from mcp.shared.auth import ProtectedResourceMetadata | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class TokenValidatorJWT(TokenValidator[AccessToken]): | ||
def __init__(self, resource_metadata: ProtectedResourceMetadata): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably needs super init: super().__init__() |
||
self._resource_metadata = resource_metadata | ||
|
||
async def validate_token(self, token: str) -> AccessToken | None: | ||
try: | ||
return await self.decode_token(token) | ||
except Exception as e: | ||
logger.error(f"Token validation failed: {e}") | ||
return None | ||
|
||
async def _get_jwks_uri(self, auth_server: str) -> str: | ||
"""Get the JWKS URI from the OIDC or OAuth well-known configuration. | ||
|
||
Args: | ||
auth_server: The base URL of the authorization server | ||
|
||
Returns: | ||
The JWKS URI | ||
|
||
Raises: | ||
ValueError: If the JWKS URI cannot be found in either OIDC or OAuth | ||
well-known configurations | ||
requests.RequestException: If there's an error fetching the configuration | ||
""" | ||
well_known_paths = [ | ||
"/.well-known/openid-configuration", # OIDC well-known | ||
"/.well-known/oauth-authorization-server", # OAuth well-known | ||
] | ||
|
||
last_error = None | ||
|
||
for path in well_known_paths: | ||
try: | ||
config_url = f"https://{auth_server}{path}" | ||
response = requests.get( | ||
config_url, | ||
timeout=10, # Add timeout to prevent hanging | ||
headers={"Accept": "application/json"}, | ||
) | ||
response.raise_for_status() # Raise an exception for bad status codes | ||
config = response.json() | ||
|
||
# Try to get JWKS URI from the configuration | ||
jwks_uri = config.get("jwks_uri") | ||
if jwks_uri: | ||
return jwks_uri | ||
|
||
except requests.RequestException as e: | ||
last_error = e | ||
logger.debug(f"Failed to fetch {path}: {e}") | ||
continue | ||
|
||
# If we get here, we couldn't find a valid JWKS URI | ||
error_msg = "Could not find jwks_uri in OIDC or OAuth well-known configurations" | ||
logger.error(f"{error_msg}. Last error: {last_error}") | ||
raise ValueError(error_msg) | ||
|
||
async def decode_token(self, token: str) -> AccessToken | None: | ||
try: | ||
auth_server = self._resource_metadata.authorization_servers[0] | ||
jwks_uri = await self._get_jwks_uri(auth_server) | ||
jwks_client = jwt.PyJWKClient(jwks_uri) | ||
signing_key = jwks_client.get_signing_key_from_jwt(token) | ||
|
||
# Rest of your decode_token method remains the same | ||
payload = jwt.decode( | ||
token, | ||
key=signing_key.key, | ||
algorithms=["RS256"], | ||
audience=self._resource_metadata.resource, | ||
issuer=f"https://{auth_server}", | ||
options={ | ||
"verify_signature": True, | ||
"verify_aud": True, | ||
"verify_iss": True, | ||
"verify_exp": True, | ||
"verify_nbf": True, | ||
"verify_iat": True, | ||
}, | ||
) | ||
|
||
return AccessToken( | ||
token=token, | ||
client_id=payload["client_id"], | ||
scopes=payload["scope"].split(" "), | ||
expires_at=payload["exp"], | ||
) | ||
except Exception as e: | ||
logger.error(f"Token validation failed: {e}") | ||
return None | ||
|
||
|
||
class ServerSettings(BaseSettings): | ||
"""Settings for the simple GitHub MCP server.""" | ||
|
||
model_config = SettingsConfigDict(env_prefix="MCP_GITHUB_") | ||
|
||
# Server settings | ||
host: str = "localhost" | ||
port: int = 8000 | ||
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000") | ||
mcp_scope: str = "user" | ||
|
||
def __init__(self, **data): | ||
"""Initialize settings with values from environment variables. | ||
|
||
Note: github_client_id and github_client_secret are required but can be | ||
loaded automatically from environment variables (MCP_GITHUB_GITHUB_CLIENT_ID | ||
and MCP_GITHUB_GITHUB_CLIENT_SECRET) and don't need to be passed explicitly. | ||
""" | ||
super().__init__(**data) | ||
|
||
|
||
def create_simple_mcp_server(settings: ServerSettings) -> FastMCP: | ||
"""Create a simple FastMCP server with GitHub OAuth.""" | ||
|
||
auth_settings = AuthSettings( | ||
issuer_url=settings.server_url, | ||
client_registration_options=ClientRegistrationOptions( | ||
enabled=True, | ||
valid_scopes=[settings.mcp_scope], | ||
default_scopes=[settings.mcp_scope], | ||
), | ||
required_scopes=[settings.mcp_scope], | ||
) | ||
|
||
app = FastMCP( | ||
name="Simple GitHub MCP Server", | ||
instructions="A simple MCP server with GitHub OAuth authentication", | ||
host=settings.host, | ||
port=settings.port, | ||
debug=True, | ||
auth=auth_settings, | ||
token_validator=TokenValidatorJWT( | ||
ProtectedResourceMetadata( | ||
resource="asdasd", | ||
authorization_servers=["https://auth.devramp.ai"], | ||
scopes_supported=["user"], | ||
) | ||
), | ||
protected_resource_metadata={ | ||
"resource": "asdasd", | ||
"authorization_servers": ["https://auth.devramp.ai"], | ||
"scopes_supported": ["user"], | ||
}, | ||
) | ||
|
||
@app.tool() | ||
async def get_user_profile() -> dict[str, Any]: | ||
"""Get the authenticated user's GitHub profile information. | ||
|
||
This is the only tool in our simple example. It requires the 'user' scope. | ||
""" | ||
return {"user": "asdasd"} | ||
|
||
return app | ||
|
||
|
||
@click.command() | ||
@click.option("--port", default=8000, help="Port to listen on") | ||
@click.option("--host", default="localhost", help="Host to bind to") | ||
@click.option( | ||
"--transport", | ||
default="streamable-http", | ||
type=click.Choice(["sse", "streamable-http"]), | ||
help="Transport protocol to use ('sse' or 'streamable-http')", | ||
) | ||
def main(port: int, host: str, transport: Literal["sse", "streamable-http"]) -> int: | ||
"""Run the simple GitHub MCP server.""" | ||
logging.basicConfig(level=logging.INFO) | ||
|
||
try: | ||
# No hardcoded credentials - all from environment variables | ||
settings = ServerSettings(host=host, port=port) | ||
except ValueError as e: | ||
logger.error( | ||
"Failed to load settings. Make sure environment variables are set:" | ||
) | ||
logger.error(" MCP_GITHUB_GITHUB_CLIENT_ID=<your-client-id>") | ||
logger.error(" MCP_GITHUB_GITHUB_CLIENT_SECRET=<your-client-secret>") | ||
logger.error(f"Error: {e}") | ||
return 1 | ||
|
||
mcp_server = create_simple_mcp_server(settings) | ||
logger.info(f"Starting server with {transport} transport") | ||
mcp_server.run(transport=transport) | ||
return 0 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
[project] | ||
name = "mcp-simple-remote-auth" | ||
version = "0.1.0" | ||
description = "A simple MCP server demonstrating OAuth authentication" | ||
readme = "README.md" | ||
requires-python = ">=3.10" | ||
authors = [{ name = "Anthropic, PBC." }] | ||
license = { text = "MIT" } | ||
dependencies = [ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needs additional pyjwt dep here |
||
"anyio>=4.5", | ||
"click>=8.1.0", | ||
"httpx>=0.27", | ||
"mcp", | ||
"pydantic>=2.0", | ||
"pydantic-settings>=2.5.2", | ||
"sse-starlette>=1.6.1", | ||
"uvicorn>=0.23.1; sys_platform != 'emscripten'", | ||
] | ||
|
||
[project.scripts] | ||
mcp-simple-remote-auth = "mcp_simple_remote_auth.server:main" | ||
|
||
[build-system] | ||
requires = ["hatchling"] | ||
build-backend = "hatchling.build" | ||
|
||
[tool.hatch.build.targets.wheel] | ||
packages = ["mcp_simple_remote_auth"] | ||
|
||
[tool.uv] | ||
dev-dependencies = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.