Skip to content

Commit 5758c0e

Browse files
Merge branch 'main' into rfc-7591-grant-type
2 parents 5f42313 + 02b7889 commit 5758c0e

File tree

28 files changed

+2721
-45
lines changed

28 files changed

+2721
-45
lines changed

README.md

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -808,10 +808,21 @@ Request additional information from users. This example shows an Elicitation dur
808808

809809
<!-- snippet-source examples/snippets/servers/elicitation.py -->
810810
```python
811+
"""Elicitation examples demonstrating form and URL mode elicitation.
812+
813+
Form mode elicitation collects structured, non-sensitive data through a schema.
814+
URL mode elicitation directs users to external URLs for sensitive operations
815+
like OAuth flows, credential collection, or payment processing.
816+
"""
817+
818+
import uuid
819+
811820
from pydantic import BaseModel, Field
812821

813822
from mcp.server.fastmcp import Context, FastMCP
814823
from mcp.server.session import ServerSession
824+
from mcp.shared.exceptions import UrlElicitationRequiredError
825+
from mcp.types import ElicitRequestURLParams
815826

816827
mcp = FastMCP(name="Elicitation Example")
817828

@@ -828,7 +839,10 @@ class BookingPreferences(BaseModel):
828839

829840
@mcp.tool()
830841
async def book_table(date: str, time: str, party_size: int, ctx: Context[ServerSession, None]) -> str:
831-
"""Book a table with date availability check."""
842+
"""Book a table with date availability check.
843+
844+
This demonstrates form mode elicitation for collecting non-sensitive user input.
845+
"""
832846
# Check if date is available
833847
if date == "2024-12-25":
834848
# Date unavailable - ask user for alternative
@@ -845,6 +859,54 @@ async def book_table(date: str, time: str, party_size: int, ctx: Context[ServerS
845859

846860
# Date available
847861
return f"[SUCCESS] Booked for {date} at {time}"
862+
863+
864+
@mcp.tool()
865+
async def secure_payment(amount: float, ctx: Context[ServerSession, None]) -> str:
866+
"""Process a secure payment requiring URL confirmation.
867+
868+
This demonstrates URL mode elicitation using ctx.elicit_url() for
869+
operations that require out-of-band user interaction.
870+
"""
871+
elicitation_id = str(uuid.uuid4())
872+
873+
result = await ctx.elicit_url(
874+
message=f"Please confirm payment of ${amount:.2f}",
875+
url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}",
876+
elicitation_id=elicitation_id,
877+
)
878+
879+
if result.action == "accept":
880+
# In a real app, the payment confirmation would happen out-of-band
881+
# and you'd verify the payment status from your backend
882+
return f"Payment of ${amount:.2f} initiated - check your browser to complete"
883+
elif result.action == "decline":
884+
return "Payment declined by user"
885+
return "Payment cancelled"
886+
887+
888+
@mcp.tool()
889+
async def connect_service(service_name: str, ctx: Context[ServerSession, None]) -> str:
890+
"""Connect to a third-party service requiring OAuth authorization.
891+
892+
This demonstrates the "throw error" pattern using UrlElicitationRequiredError.
893+
Use this pattern when the tool cannot proceed without user authorization.
894+
"""
895+
elicitation_id = str(uuid.uuid4())
896+
897+
# Raise UrlElicitationRequiredError to signal that the client must complete
898+
# a URL elicitation before this request can be processed.
899+
# The MCP framework will convert this to a -32042 error response.
900+
raise UrlElicitationRequiredError(
901+
[
902+
ElicitRequestURLParams(
903+
mode="url",
904+
message=f"Authorization required to connect to {service_name}",
905+
url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}",
906+
elicitationId=elicitation_id,
907+
)
908+
]
909+
)
848910
```
849911

850912
_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# MCP Conformance Auth Client
2+
3+
A Python OAuth client designed for use with the MCP conformance test framework.
4+
5+
## Overview
6+
7+
This client implements OAuth authentication for MCP and is designed to work automatically with the conformance test framework without requiring user interaction. It programmatically fetches authorization URLs and extracts auth codes from redirects.
8+
9+
## Installation
10+
11+
```bash
12+
cd examples/clients/conformance-auth-client
13+
uv sync
14+
```
15+
16+
## Usage with Conformance Tests
17+
18+
Run the auth conformance tests against this Python client:
19+
20+
```bash
21+
# From the conformance repository
22+
npx @modelcontextprotocol/conformance client \
23+
--command "uv run --directory /path/to/python-sdk/examples/clients/conformance-auth-client python -m mcp_conformance_auth_client" \
24+
--scenario auth/basic-dcr
25+
```
26+
27+
Available auth test scenarios:
28+
29+
- `auth/basic-dcr` - Tests OAuth Dynamic Client Registration flow
30+
- `auth/basic-metadata-var1` - Tests OAuth with authorization metadata
31+
32+
## How It Works
33+
34+
Unlike interactive OAuth clients that open a browser for user authentication, this client:
35+
36+
1. Receives the authorization URL from the OAuth provider
37+
2. Makes an HTTP request to that URL directly (without following redirects)
38+
3. Extracts the authorization code from the redirect response
39+
4. Uses the code to complete the OAuth token exchange
40+
41+
This allows the conformance test framework's mock OAuth server to automatically provide auth codes without human interaction.
42+
43+
## Direct Usage
44+
45+
You can also run the client directly:
46+
47+
```bash
48+
uv run python -m mcp_conformance_auth_client http://localhost:3000/mcp
49+
```
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env python3
2+
"""
3+
MCP OAuth conformance test client.
4+
5+
This client is designed to work with the MCP conformance test framework.
6+
It automatically handles OAuth flows without user interaction by programmatically
7+
fetching the authorization URL and extracting the auth code from the redirect.
8+
9+
Usage:
10+
python -m mcp_conformance_auth_client <server-url>
11+
"""
12+
13+
import asyncio
14+
import logging
15+
import sys
16+
from datetime import timedelta
17+
from urllib.parse import ParseResult, parse_qs, urlparse
18+
19+
import httpx
20+
from mcp import ClientSession
21+
from mcp.client.auth import OAuthClientProvider, TokenStorage
22+
from mcp.client.streamable_http import streamablehttp_client
23+
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
24+
from pydantic import AnyUrl
25+
26+
# Set up logging to stderr (stdout is for conformance test output)
27+
logging.basicConfig(
28+
level=logging.DEBUG,
29+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
30+
stream=sys.stderr,
31+
)
32+
logger = logging.getLogger(__name__)
33+
34+
35+
class InMemoryTokenStorage(TokenStorage):
36+
"""Simple in-memory token storage for conformance testing."""
37+
38+
def __init__(self):
39+
self._tokens: OAuthToken | None = None
40+
self._client_info: OAuthClientInformationFull | None = None
41+
42+
async def get_tokens(self) -> OAuthToken | None:
43+
return self._tokens
44+
45+
async def set_tokens(self, tokens: OAuthToken) -> None:
46+
self._tokens = tokens
47+
48+
async def get_client_info(self) -> OAuthClientInformationFull | None:
49+
return self._client_info
50+
51+
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
52+
self._client_info = client_info
53+
54+
55+
class ConformanceOAuthCallbackHandler:
56+
"""
57+
OAuth callback handler that automatically fetches the authorization URL
58+
and extracts the auth code, without requiring user interaction.
59+
60+
This mimics the behavior of the TypeScript ConformanceOAuthProvider.
61+
"""
62+
63+
def __init__(self):
64+
self._auth_code: str | None = None
65+
self._state: str | None = None
66+
67+
async def handle_redirect(self, authorization_url: str) -> None:
68+
"""
69+
Fetch the authorization URL and extract the auth code from the redirect.
70+
71+
The conformance test server returns a redirect with the auth code,
72+
so we can capture it programmatically.
73+
"""
74+
logger.debug(f"Fetching authorization URL: {authorization_url}")
75+
76+
async with httpx.AsyncClient() as client:
77+
response = await client.get(
78+
authorization_url,
79+
follow_redirects=False, # Don't follow redirects automatically
80+
)
81+
82+
# Check for redirect response
83+
if response.status_code in (301, 302, 303, 307, 308):
84+
location = response.headers.get("location")
85+
if location:
86+
redirect_url: ParseResult = urlparse(location)
87+
query_params: dict[str, list[str]] = parse_qs(redirect_url.query)
88+
89+
if "code" in query_params:
90+
self._auth_code = query_params["code"][0]
91+
state_values = query_params.get("state")
92+
self._state = state_values[0] if state_values else None
93+
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
94+
return
95+
else:
96+
raise RuntimeError(f"No auth code in redirect URL: {location}")
97+
else:
98+
raise RuntimeError(f"No redirect location received from {authorization_url}")
99+
else:
100+
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")
101+
102+
async def handle_callback(self) -> tuple[str, str | None]:
103+
"""Return the captured auth code and state, then clear them for potential reuse."""
104+
if self._auth_code is None:
105+
raise RuntimeError("No authorization code available - was handle_redirect called?")
106+
auth_code = self._auth_code
107+
state = self._state
108+
# Clear the stored values so the next auth flow gets fresh ones
109+
self._auth_code = None
110+
self._state = None
111+
return auth_code, state
112+
113+
114+
async def run_client(server_url: str) -> None:
115+
"""
116+
Run the conformance test client against the given server URL.
117+
118+
This function:
119+
1. Connects to the MCP server with OAuth authentication
120+
2. Initializes the session
121+
3. Lists available tools
122+
4. Calls a test tool
123+
"""
124+
logger.debug(f"Starting conformance auth client for {server_url}")
125+
126+
# Create callback handler that will automatically fetch auth codes
127+
callback_handler = ConformanceOAuthCallbackHandler()
128+
129+
# Create OAuth authentication handler
130+
oauth_auth = OAuthClientProvider(
131+
server_url=server_url,
132+
client_metadata=OAuthClientMetadata(
133+
client_name="conformance-auth-client",
134+
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
135+
grant_types=["authorization_code", "refresh_token"],
136+
response_types=["code"],
137+
),
138+
storage=InMemoryTokenStorage(),
139+
redirect_handler=callback_handler.handle_redirect,
140+
callback_handler=callback_handler.handle_callback,
141+
)
142+
143+
# Connect using streamable HTTP transport with OAuth
144+
async with streamablehttp_client(
145+
url=server_url,
146+
auth=oauth_auth,
147+
timeout=timedelta(seconds=30),
148+
sse_read_timeout=timedelta(seconds=60),
149+
) as (read_stream, write_stream, _):
150+
async with ClientSession(read_stream, write_stream) as session:
151+
# Initialize the session
152+
await session.initialize()
153+
logger.debug("Successfully connected and initialized MCP session")
154+
155+
# List tools
156+
tools_result = await session.list_tools()
157+
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")
158+
159+
# Call test tool (expected by conformance tests)
160+
try:
161+
result = await session.call_tool("test-tool", {})
162+
logger.debug(f"Called test-tool, result: {result}")
163+
except Exception as e:
164+
logger.debug(f"Tool call result/error: {e}")
165+
166+
logger.debug("Connection closed successfully")
167+
168+
169+
def main() -> None:
170+
"""Main entry point for the conformance auth client."""
171+
if len(sys.argv) != 2:
172+
print(f"Usage: {sys.argv[0]} <server-url>", file=sys.stderr)
173+
sys.exit(1)
174+
175+
server_url = sys.argv[1]
176+
177+
try:
178+
asyncio.run(run_client(server_url))
179+
except Exception:
180+
logger.exception("Client failed")
181+
sys.exit(1)
182+
183+
184+
if __name__ == "__main__":
185+
main()
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Allow running the module with python -m."""
2+
3+
from . import main
4+
5+
if __name__ == "__main__":
6+
main()
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[project]
2+
name = "mcp-conformance-auth-client"
3+
version = "0.1.0"
4+
description = "OAuth conformance test client for MCP"
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
authors = [{ name = "Anthropic" }]
8+
keywords = ["mcp", "oauth", "client", "auth", "conformance", "testing"]
9+
license = { text = "MIT" }
10+
classifiers = [
11+
"Development Status :: 4 - Beta",
12+
"Intended Audience :: Developers",
13+
"License :: OSI Approved :: MIT License",
14+
"Programming Language :: Python :: 3",
15+
"Programming Language :: Python :: 3.10",
16+
]
17+
dependencies = ["mcp", "httpx>=0.28.1"]
18+
19+
[project.scripts]
20+
mcp-conformance-auth-client = "mcp_conformance_auth_client:main"
21+
22+
[build-system]
23+
requires = ["hatchling"]
24+
build-backend = "hatchling.build"
25+
26+
[tool.hatch.build.targets.wheel]
27+
packages = ["mcp_conformance_auth_client"]
28+
29+
[tool.pyright]
30+
include = ["mcp_conformance_auth_client"]
31+
venvPath = "."
32+
venv = ".venv"
33+
34+
[tool.ruff.lint]
35+
select = ["E", "F", "I"]
36+
ignore = []
37+
38+
[tool.ruff]
39+
line-length = 120
40+
target-version = "py310"
41+
42+
[dependency-groups]
43+
dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"]

0 commit comments

Comments
 (0)