Skip to content

Commit 36be8db

Browse files
committed
tests and readme
1 parent f93ab34 commit 36be8db

File tree

6 files changed

+970
-87
lines changed

6 files changed

+970
-87
lines changed

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,7 @@ async def main():
801801
The SDK supports OAuth 2.0 client authentication for secure access to MCP servers that require authentication:
802802

803803
```python
804-
from mcp.client.auth import OAuthClientProvider, UnauthorizedError
804+
from mcp.client.auth import UnauthorizedError
805805
from mcp.client.oauth_providers import InMemoryOAuthProvider
806806
from mcp.client.streamable_http import streamablehttp_client
807807
from mcp.shared.auth import OAuthClientMetadata
@@ -817,6 +817,7 @@ oauth_provider = InMemoryOAuthProvider(
817817
),
818818
)
819819

820+
820821
async def main():
821822
# Connect with OAuth authentication
822823
async with streamablehttp_client(
@@ -834,6 +835,7 @@ async def main():
834835
# Handle authorization required
835836
print("Authorization required. Check your browser.")
836837

838+
837839
# Handle OAuth callback after user authorization
838840
async def handle_callback(authorization_code: str):
839841
from mcp.client.streamable_http import StreamableHTTPTransport
@@ -856,14 +858,17 @@ You can implement custom OAuth storage by creating your own provider:
856858
```python
857859
from mcp.client.oauth_providers import InMemoryOAuthProvider
858860

861+
859862
class DatabaseOAuthProvider(InMemoryOAuthProvider):
860863
async def save_tokens(self, tokens):
861864
# Save to database
862-
await db.save_tokens(self.client_id, tokens)
865+
# await db.save_tokens(self.client_id, tokens)
866+
pass
863867

864868
async def tokens(self):
865869
# Load from database
866-
return await db.load_tokens(self.client_id)
870+
# return await db.load_tokens(self.client_id)
871+
return None
867872

868873
# Implement other methods as needed...
869874
```
Lines changed: 17 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,125 +1,70 @@
11
# Simple Auth Client Example
22

3-
This example demonstrates how to use the MCP Python SDK to create a client that connects to an MCP server using OAuth authentication over streamable HTTP transport.
3+
A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP transport.
44

55
## Features
66

77
- OAuth 2.0 authentication with PKCE
88
- Streamable HTTP transport
99
- Interactive command-line interface
10-
- Tool listing and execution
11-
12-
## Prerequisites
13-
14-
1. Python 3.9 or higher
15-
2. An MCP server that supports OAuth authentication (like `mcp-simple-auth`)
16-
3. uv for dependency management
1710

1811
## Installation
1912

2013
```bash
2114
cd examples/clients/simple-auth-client
22-
uv install
15+
uv sync --reinstall
2316
```
2417

2518
## Usage
2619

27-
### 1. Start the Auth Server
28-
29-
First, start the MCP auth server in another terminal:
20+
### 1. Start an MCP server with OAuth support
3021

3122
```bash
23+
# Example with mcp-simple-auth
3224
cd path/to/mcp-simple-auth
3325
uv run mcp-simple-auth --transport streamable-http --port 3001
3426
```
3527

36-
### 2. Run the Client
28+
### 2. Run the client
3729

3830
```bash
39-
# Run the client
4031
uv run mcp-simple-auth-client
4132

4233
# Or with custom server URL
4334
MCP_SERVER_URL=http://localhost:3001 uv run mcp-simple-auth-client
4435
```
4536

46-
### 3. Authentication Flow
47-
48-
1. The client will attempt to connect to the server
49-
2. If authentication is required, the client will open your default browser
50-
3. Complete the OAuth flow in the browser
51-
4. Return to the client - it should now be connected
52-
53-
### 4. Interactive Commands
37+
### 3. Complete OAuth flow
5438

55-
Once connected, you can use these commands:
39+
The client will open your browser for authentication. After completing OAuth, you can use commands:
5640

57-
- `list` - List available tools from the server
58-
- `call <tool_name> [args]` - Call a tool with optional JSON arguments
59-
- `quit` - Exit the client
41+
- `list` - List available tools
42+
- `call <tool_name> [args]` - Call a tool with optional JSON arguments
43+
- `quit` - Exit
6044

61-
### Example Session
45+
## Example
6246

6347
```
64-
=� Simple MCP Auth Client
48+
🔐 Simple MCP Auth Client
6549
Connecting to: http://localhost:3001
6650
6751
Please visit the following URL to authorize the application:
6852
http://localhost:3001/authorize?response_type=code&client_id=...
6953
70-
 Connected to MCP server at http://localhost:3001
71-
Session ID: abc123
72-
73-
<� Interactive MCP Client
74-
Commands:
75-
list - List available tools
76-
call <tool_name> [args] - Call a tool
77-
quit - Exit the client
54+
✅ Connected to MCP server at http://localhost:3001
7855
7956
mcp> list
80-
81-
=� Available tools:
82-
1. echo
83-
Description: Echo back the input text
57+
📋 Available tools:
58+
1. echo - Echo back the input text
8459
8560
mcp> call echo {"text": "Hello, world!"}
86-
87-
=' Tool 'echo' result:
61+
🔧 Tool 'echo' result:
8862
Hello, world!
8963
9064
mcp> quit
91-
=K Goodbye!
65+
👋 Goodbye!
9266
```
9367

9468
## Configuration
9569

96-
You can customize the client behavior with environment variables:
97-
9870
- `MCP_SERVER_URL` - Server URL (default: http://localhost:3001)
99-
- `AUTH_CODE` - Authorization code for completing OAuth flow
100-
101-
## Implementation Details
102-
103-
This example shows how to:
104-
105-
1. **Create an OAuth provider** - Implement the `OAuthClientProvider` protocol
106-
2. **Use streamable HTTP transport** - Connect using the `streamablehttp_client` context manager
107-
3. **Handle authentication** - Manage OAuth flow including browser redirect
108-
4. **Interactive tool usage** - List and call tools from the command line
109-
110-
The key components are:
111-
112-
- `SimpleOAuthProvider` - Minimal OAuth provider implementation
113-
- `SimpleAuthClient` - Main client class that handles connection and tool operations
114-
- Interactive loop for user commands
115-
116-
## Error Handling
117-
118-
The client handles common error scenarios:
119-
120-
- Server connection failures
121-
- Authentication errors
122-
- Invalid tool calls
123-
- Network timeouts
124-
125-
All errors are displayed with helpful messages to guide debugging.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
"""Simple OAuth client for MCP simple-auth server."""
1+
"""Simple OAuth client for MCP simple-auth server."""

examples/clients/simple-auth-client/mcp_simple_auth_client/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ class SimpleAuthClient:
135135
def __init__(self, server_url: str):
136136
self.server_url = server_url
137137
# Extract base URL for auth server (remove /mcp endpoint for auth endpoints)
138-
# Use default redirect URI - this is where the auth server will redirect the user
138+
# Use default redirect URI - this is where the auth server will redirect
139139
# The user will need to copy the authorization code from this callback URL
140140
self.session: ClientSession | None = None
141141

src/mcp/client/auth.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ async def _validate_token_scopes(self, token_response: OAuthToken) -> None:
268268
set of scopes than requested, but must not grant additional scopes.
269269
"""
270270
if not token_response.scope:
271-
# If no scope is returned, validation passes (server didn't grant anything extra)
271+
# If no scope is returned, validation passes
272+
# (server didn't grant anything extra)
272273
return
273274

274275
# Get the originally requested scopes
@@ -306,7 +307,8 @@ async def _validate_token_scopes(self, token_response: OAuthToken) -> None:
306307
# If no scopes were originally requested (fell back to server defaults),
307308
# accept whatever the server returned
308309
logger.debug(
309-
f"No specific scopes were requested, accepting server-granted scopes: {returned_scopes}"
310+
f"No specific scopes were requested, accepting server-granted "
311+
f"scopes: {returned_scopes}"
310312
)
311313

312314
async def initialize(self) -> None:
@@ -372,7 +374,7 @@ async def _perform_oauth_flow(self) -> None:
372374
auth_params = {
373375
"response_type": "code",
374376
"client_id": client_info.client_id,
375-
"redirect_uri": self.client_metadata.redirect_uris[0],
377+
"redirect_uri": str(self.client_metadata.redirect_uris[0]),
376378
"state": secrets.token_urlsafe(32),
377379
"code_challenge": self._code_challenge,
378380
"code_challenge_method": "S256",
@@ -396,9 +398,15 @@ async def _perform_oauth_flow(self) -> None:
396398

397399
auth_code, returned_state = await self.callback_handler()
398400

399-
# Validate state parameter
400-
if returned_state != auth_params["state"]:
401-
raise Exception("State parameter mismatch")
401+
# Validate state parameter using constant-time comparison
402+
# to prevent timing attacks
403+
if returned_state is None:
404+
raise Exception("State parameter mismatch - possible CSRF attack")
405+
# Type cast to ensure both args are str for compare_digest
406+
expected_state = str(auth_params["state"])
407+
actual_state = str(returned_state)
408+
if not secrets.compare_digest(actual_state, expected_state):
409+
raise Exception("State parameter mismatch - possible CSRF attack")
402410

403411
if not auth_code:
404412
raise Exception("No authorization code received")
@@ -438,9 +446,20 @@ async def _exchange_code_for_token(
438446
)
439447

440448
if response.status_code != 200:
441-
raise Exception(
442-
f"Token exchange failed: {response.status_code} {response.text}"
443-
)
449+
# Try to parse OAuth error response, otherwise use basic error
450+
try:
451+
error_data = response.json()
452+
error_msg = error_data.get(
453+
"error_description", error_data.get("error", "Unknown error")
454+
)
455+
raise Exception(
456+
f"Token exchange failed: {error_msg} "
457+
f"(HTTP {response.status_code})"
458+
)
459+
except Exception:
460+
raise Exception(
461+
f"Token exchange failed: {response.status_code} {response.text}"
462+
)
444463

445464
# Parse and store tokens
446465
token_response = OAuthToken.model_validate(response.json())
@@ -499,7 +518,8 @@ async def _refresh_access_token(self) -> bool:
499518
# Parse and store new tokens
500519
token_response = OAuthToken.model_validate(response.json())
501520

502-
# Validate returned scopes against requested scopes (OAuth 2.1 Section 3.3)
521+
# Validate returned scopes against requested scopes
522+
# (OAuth 2.1 Section 3.3)
503523
await self._validate_token_scopes(token_response)
504524

505525
# Calculate expiry time if available

0 commit comments

Comments
 (0)