Skip to content

Commit 39e1602

Browse files
add example of a server protected by oauth (#578)
1 parent 9aace5e commit 39e1602

File tree

10 files changed

+1210
-44
lines changed

10 files changed

+1210
-44
lines changed

docs/configuration.mdx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,8 @@ authorization:
136136
issuer_url: https://auth.example.com
137137
resource_server_url: https://agent.example.com/mcp
138138
required_scopes: ["mcp.read", "mcp.write"]
139-
introspection_endpoint: https://auth.example.com/oauth/introspect
140-
introspection_client_id: ${INTROSPECTION_CLIENT_ID}
141-
introspection_client_secret: ${INTROSPECTION_CLIENT_SECRET}
139+
client_id: ${INTROSPECTION_CLIENT_ID}
140+
client_secret: ${INTROSPECTION_CLIENT_SECRET}
142141
143142
oauth:
144143
callback_base_url: https://agent.example.com
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# OAuth protected resource example
2+
3+
This example shows how to integrate OAuth2 authentication to protect your MCP.
4+
5+
## 1. App set up
6+
7+
First, clone the repo and navigate to the functions example:
8+
9+
```bash
10+
git clone https://github.com/lastmile-ai/mcp-agent.git
11+
cd mcp-agent/examples/oauth/protected_by_oauth
12+
```
13+
14+
Install `uv` (if you don’t have it):
15+
16+
```bash
17+
pip install uv
18+
```
19+
20+
Sync `mcp-agent` project dependencies:
21+
22+
```bash
23+
uv sync
24+
```
25+
26+
## 2. Client registration
27+
To protect your MCP with OAuth2, you first need to register your application with an OAuth2 provider, as MCP follows the Dynamic Client Registration Protocol.
28+
You can configure either your own OAuth2 server, or use the one provided by MCP Agent Cloud (https://auth.mcp-agent.com).
29+
30+
If you do not have a client registered already, you can use the `registration.py` script provided with this example.
31+
At the top of the file,
32+
1. update the URL for your authentication server,
33+
2. set the redirect URIs to point to your MCP endpoint (e.g. `https://your-mcp-endpoint.com/callback`), and
34+
3. set the name for your client.
35+
36+
Run the script to register your client:
37+
```bash
38+
uv run registration.py
39+
```
40+
41+
You should see something like
42+
43+
```
44+
Client registered successfully!
45+
{
46+
# detailed json response
47+
}
48+
49+
=== Save these credentials ===
50+
Client ID: abc-123
51+
Client Secret: xyz-987
52+
```
53+
54+
Take a note of the client id and client secret printed at the end, as you will need them in the next step.
55+
56+
## 3. Configure your MCP
57+
Next, you need to configure your MCP to use the OAuth2 credentials you just created.
58+
In `main.py`, update these settings:
59+
60+
```python
61+
auth_server = "<auth server url>"
62+
resource_server = "http://localhost:8000" # This server's URL
63+
64+
client_id = "<the client id returned by the registration.py script>"
65+
client_secret = "<the client secret returned by the registration.py script>"
66+
```
67+
68+
## 4. Run the example
69+
70+
With these in place, you can run the server using
71+
72+
```python
73+
uv run main.py
74+
```
75+
76+
This will start an MCP server protected by OAuth2.
77+
You can test it using an MCP client that supports OAuth2 authentication, such as [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector).
78+
79+
80+
## Further reading
81+
More details on oauth authorization and the MCP protocol can be found at [https://modelcontextprotocol.io/specification/draft/basic/authorization](https://modelcontextprotocol.io/specification/draft/basic/authorization).
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Demonstration of an MCP agent server configured with OAuth.
3+
"""
4+
5+
import asyncio
6+
from typing import Optional
7+
from pydantic import AnyHttpUrl
8+
9+
from mcp_agent.core.context import Context as AppContext
10+
11+
from mcp_agent.app import MCPApp
12+
from mcp_agent.server.app_server import create_mcp_server_for_app
13+
from mcp_agent.config import Settings, LoggerSettings, \
14+
OAuthTokenStoreSettings, OAuthSettings, MCPAuthorizationServerSettings
15+
16+
17+
auth_server = "https://auth.mcp-agent.com" # the MCP Agent Cloud auth server, or replace with your own
18+
resource_server = "http://localhost:8000" # This server's URL
19+
20+
client_id = "<client id from registration.py>"
21+
client_secret = "<client secret from registration.py>"
22+
23+
settings = Settings(
24+
execution_engine="asyncio",
25+
logger=LoggerSettings(level="info"),
26+
authorization=MCPAuthorizationServerSettings(
27+
enabled=True,
28+
issuer_url=AnyHttpUrl(auth_server),
29+
resource_server_url=AnyHttpUrl(resource_server),
30+
client_id=client_id,
31+
client_secret=client_secret,
32+
required_scopes=["mcp"],
33+
expected_audiences=[client_id],
34+
),
35+
oauth=OAuthSettings(
36+
callback_base_url=AnyHttpUrl(resource_server),
37+
flow_timeout_seconds=300,
38+
token_store=OAuthTokenStoreSettings(refresh_leeway_seconds=60),
39+
)
40+
)
41+
42+
43+
# Define the MCPApp instance. The server created for this app will advertise the
44+
# MCP logging capability and forward structured logs upstream to connected clients.
45+
app = MCPApp(
46+
name="oauth_demo",
47+
description="Basic agent server example",
48+
settings=settings,
49+
)
50+
51+
52+
@app.tool(name="hello_world")
53+
async def hello(app_ctx: Optional[AppContext] = None) -> str:
54+
# Use the context's app if available for proper logging with upstream_session
55+
_app = app_ctx.app if app_ctx else app
56+
# Ensure the app's logger is bound to the current context with upstream_session
57+
if _app._logger and hasattr(_app._logger, "_bound_context"):
58+
_app._logger._bound_context = app_ctx
59+
60+
if app_ctx.current_user:
61+
user = app_ctx.current_user
62+
if user.claims and "username" in user.claims:
63+
return f"Hello, {user.claims['username']}!"
64+
else:
65+
return f"Hello, user with ID {user.subject}!"
66+
else:
67+
return "Hello, anonymous user!"
68+
69+
async def main():
70+
async with app.run() as agent_app:
71+
# Log registered workflows and agent configurations
72+
agent_app.logger.info(f"Creating MCP server for {agent_app.name}")
73+
74+
agent_app.logger.info("Registered workflows:")
75+
for workflow_id in agent_app.workflows:
76+
agent_app.logger.info(f" - {workflow_id}")
77+
78+
# Create the MCP server that exposes both workflows and agent configurations,
79+
# optionally using custom FastMCP settings
80+
mcp_server = create_mcp_server_for_app(agent_app)
81+
agent_app.logger.info(f"MCP Server settings: {mcp_server.settings}")
82+
83+
# Run the server
84+
await mcp_server.run_sse_async()
85+
86+
87+
if __name__ == "__main__":
88+
asyncio.run(main())
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import requests
2+
import json
3+
4+
# Authorization server URL. This can either be the MCP Agent Clound authorization server (as currently configured),
5+
# or your own.
6+
auth_server_url = "https://auth.mcp-agent.com"
7+
redirect_uris = [
8+
# These are the redirect URIs for MCP Inspector. Replace with your app's URIs.
9+
"http://localhost:6274/oauth/callback",
10+
"http://localhost:6274/oauth/callback/debug"
11+
]
12+
client_name = "My Python Application"
13+
14+
# Fetch the registration endpoint dynamically from the .well-known/oauth-authorization-server details
15+
well_known_url = f"{auth_server_url}/.well-known/oauth-authorization-server"
16+
response = requests.get(well_known_url)
17+
18+
if response.status_code == 200:
19+
well_known_details = response.json()
20+
registration_endpoint = well_known_details.get("registration_endpoint")
21+
if not registration_endpoint:
22+
raise ValueError("Registration endpoint not found in .well-known details")
23+
else:
24+
raise ValueError(f"Failed to fetch .well-known details: {response.status_code}")
25+
26+
27+
# Client registration request
28+
registration_request = {
29+
"client_name": client_name,
30+
"redirect_uris": redirect_uris,
31+
"grant_types": [
32+
"authorization_code",
33+
"refresh_token"
34+
],
35+
"scope": "mcp",
36+
# use client_secret_basic when testing with MCP Inspector
37+
"token_endpoint_auth_method": "client_secret_basic"
38+
}
39+
40+
print(f"Registering client at: {registration_endpoint}")
41+
42+
# Register the client
43+
response = requests.post(
44+
registration_endpoint,
45+
json=registration_request,
46+
headers={"Content-Type": "application/json"}
47+
)
48+
49+
if response.status_code in [200, 201]:
50+
client_info = response.json()
51+
print("Client registered successfully!")
52+
print(json.dumps(client_info, indent=2))
53+
54+
# Save credentials for later use
55+
print("\n=== Save these credentials ===")
56+
print(f"Client ID: {client_info['client_id']}")
57+
print(f"Client Secret: {client_info['client_secret']}")
58+
else:
59+
print(f"Registration failed with status {response.status_code}")
60+
print(response.text)

schema/mcp-agent.config.schema.json

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -698,22 +698,7 @@
698698
"title": "Jwks Uri",
699699
"description": "Optional JWKS endpoint for validating JWT access tokens."
700700
},
701-
"introspection_endpoint": {
702-
"anyOf": [
703-
{
704-
"format": "uri",
705-
"minLength": 1,
706-
"type": "string"
707-
},
708-
{
709-
"type": "null"
710-
}
711-
],
712-
"default": null,
713-
"title": "Introspection Endpoint",
714-
"description": "Optional OAuth introspection endpoint for opaque tokens."
715-
},
716-
"introspection_client_id": {
701+
"client_id": {
717702
"anyOf": [
718703
{
719704
"type": "string"
@@ -723,10 +708,10 @@
723708
}
724709
],
725710
"default": null,
726-
"title": "Introspection Client Id",
711+
"title": "Client Id",
727712
"description": "Client id to use when calling the introspection endpoint."
728713
},
729-
"introspection_client_secret": {
714+
"client_secret": {
730715
"anyOf": [
731716
{
732717
"type": "string"
@@ -736,7 +721,7 @@
736721
}
737722
],
738723
"default": null,
739-
"title": "Introspection Client Secret",
724+
"title": "Client Secret",
740725
"description": "Client secret to use when calling the introspection endpoint."
741726
},
742727
"token_cache_ttl_seconds": {

src/mcp_agent/config.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,10 @@ class MCPAuthorizationServerSettings(BaseModel):
4747
jwks_uri: AnyHttpUrl | None = None
4848
"""Optional JWKS endpoint for validating JWT access tokens."""
4949

50-
introspection_endpoint: AnyHttpUrl | None = None
51-
"""Optional OAuth introspection endpoint for opaque tokens."""
52-
53-
introspection_client_id: str | None = None
50+
client_id: str | None = None
5451
"""Client id to use when calling the introspection endpoint."""
5552

56-
introspection_client_secret: str | None = None
53+
client_secret: str | None = None
5754
"""Client secret to use when calling the introspection endpoint."""
5855

5956
token_cache_ttl_seconds: int = Field(300, ge=0)
@@ -1162,6 +1159,9 @@ def _find_config(cls, filenames: List[str]) -> Path | None:
11621159
return None
11631160

11641161

1162+
Settings.model_rebuild()
1163+
1164+
11651165
class PreloadSettings(BaseSettings):
11661166
"""
11671167
Class for preloaded settings of the MCP Agent application.

src/mcp_agent/oauth/metadata.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,33 @@ async def fetch_authorization_server_metadata(
3232
return OAuthMetadata.model_validate(response.json())
3333

3434

35+
async def fetch_authorization_server_metadata_from_issuer(
36+
client: httpx.AsyncClient,
37+
issuer_url: str,
38+
) -> OAuthMetadata:
39+
"""Fetch OAuth authorization server metadata from the well-known endpoint.
40+
41+
Given an issuer URL, constructs the well-known OAuth authorization server
42+
metadata URL and fetches the metadata.
43+
44+
Args:
45+
client: HTTP client to use for the request
46+
issuer_url: The issuer URL (e.g., "https://auth.example.com")
47+
48+
Returns:
49+
OAuthMetadata containing authorization server metadata including introspection_endpoint
50+
"""
51+
from httpx import URL
52+
53+
parsed_url = URL(issuer_url)
54+
metadata_url = str(
55+
parsed_url.copy_with(
56+
path="/.well-known/oauth-authorization-server" + parsed_url.path
57+
)
58+
)
59+
return await fetch_authorization_server_metadata(client, metadata_url)
60+
61+
3562
def select_authorization_server(
3663
metadata: ProtectedResourceMetadata,
3764
preferred: str | None = None,

0 commit comments

Comments
 (0)