Skip to content

Commit 91f3533

Browse files
Merge branch 'main' into rfc-7591-grant-type
2 parents 388f529 + 8e02fc1 commit 91f3533

File tree

36 files changed

+2555
-103
lines changed

36 files changed

+2555
-103
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -948,8 +948,9 @@ async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str:
948948
max_tokens=100,
949949
)
950950

951-
if all(c.type == "text" for c in result.content_as_list):
952-
return "\n".join(c.text for c in result.content_as_list if c.type == "text")
951+
# Since we're not passing tools param, result.content is single content
952+
if result.content.type == "text":
953+
return result.content.text
953954
return str(result.content)
954955
```
955956

examples/clients/conformance-auth-client/mcp_conformance_auth_client/__init__.py

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,58 @@
77
fetching the authorization URL and extracting the auth code from the redirect.
88
99
Usage:
10-
python -m mcp_conformance_auth_client <server-url>
10+
python -m mcp_conformance_auth_client <scenario> <server-url>
11+
12+
Environment Variables:
13+
MCP_CONFORMANCE_CONTEXT - JSON object containing test credentials:
14+
{
15+
"client_id": "...",
16+
"client_secret": "...", # For client_secret_basic flow
17+
"private_key_pem": "...", # For private_key_jwt flow
18+
"signing_algorithm": "ES256" # Optional, defaults to ES256
19+
}
20+
21+
Scenarios:
22+
auth/* - Authorization code flow scenarios (default behavior)
23+
auth/client-credentials-jwt - Client credentials with JWT authentication (SEP-1046)
24+
auth/client-credentials-basic - Client credentials with client_secret_basic
1125
"""
1226

1327
import asyncio
28+
import json
1429
import logging
30+
import os
1531
import sys
1632
from datetime import timedelta
1733
from urllib.parse import ParseResult, parse_qs, urlparse
1834

1935
import httpx
2036
from mcp import ClientSession
2137
from mcp.client.auth import OAuthClientProvider, TokenStorage
38+
from mcp.client.auth.extensions.client_credentials import (
39+
ClientCredentialsOAuthProvider,
40+
PrivateKeyJWTOAuthProvider,
41+
SignedJWTParameters,
42+
)
2243
from mcp.client.streamable_http import streamablehttp_client
2344
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
2445
from pydantic import AnyUrl
2546

47+
48+
def get_conformance_context() -> dict:
49+
"""Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable."""
50+
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
51+
if not context_json:
52+
raise RuntimeError(
53+
"MCP_CONFORMANCE_CONTEXT environment variable not set. "
54+
"Expected JSON with client_id, client_secret, and/or private_key_pem."
55+
)
56+
try:
57+
return json.loads(context_json)
58+
except json.JSONDecodeError as e:
59+
raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e
60+
61+
2662
# Set up logging to stderr (stdout is for conformance test output)
2763
logging.basicConfig(
2864
level=logging.DEBUG,
@@ -111,17 +147,17 @@ async def handle_callback(self) -> tuple[str, str | None]:
111147
return auth_code, state
112148

113149

114-
async def run_client(server_url: str) -> None:
150+
async def run_authorization_code_client(server_url: str) -> None:
115151
"""
116-
Run the conformance test client against the given server URL.
152+
Run the conformance test client with authorization code flow.
117153
118154
This function:
119-
1. Connects to the MCP server with OAuth authentication
155+
1. Connects to the MCP server with OAuth authorization code flow
120156
2. Initializes the session
121157
3. Lists available tools
122158
4. Calls a test tool
123159
"""
124-
logger.debug(f"Starting conformance auth client for {server_url}")
160+
logger.debug(f"Starting conformance auth client (authorization_code) for {server_url}")
125161

126162
# Create callback handler that will automatically fetch auth codes
127163
callback_handler = ConformanceOAuthCallbackHandler()
@@ -140,6 +176,89 @@ async def run_client(server_url: str) -> None:
140176
callback_handler=callback_handler.handle_callback,
141177
)
142178

179+
await _run_session(server_url, oauth_auth)
180+
181+
182+
async def run_client_credentials_jwt_client(server_url: str) -> None:
183+
"""
184+
Run the conformance test client with client credentials flow using private_key_jwt (SEP-1046).
185+
186+
This function:
187+
1. Connects to the MCP server with OAuth client_credentials grant
188+
2. Uses private_key_jwt authentication with credentials from MCP_CONFORMANCE_CONTEXT
189+
3. Initializes the session
190+
4. Lists available tools
191+
5. Calls a test tool
192+
"""
193+
logger.debug(f"Starting conformance auth client (client_credentials_jwt) for {server_url}")
194+
195+
# Load credentials from environment
196+
context = get_conformance_context()
197+
client_id = context.get("client_id")
198+
private_key_pem = context.get("private_key_pem")
199+
signing_algorithm = context.get("signing_algorithm", "ES256")
200+
201+
if not client_id:
202+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
203+
if not private_key_pem:
204+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'")
205+
206+
# Create JWT parameters for SDK-signed assertions
207+
jwt_params = SignedJWTParameters(
208+
issuer=client_id,
209+
subject=client_id,
210+
signing_algorithm=signing_algorithm,
211+
signing_key=private_key_pem,
212+
)
213+
214+
# Create OAuth provider for client_credentials with private_key_jwt
215+
oauth_auth = PrivateKeyJWTOAuthProvider(
216+
server_url=server_url,
217+
storage=InMemoryTokenStorage(),
218+
client_id=client_id,
219+
assertion_provider=jwt_params.create_assertion_provider(),
220+
)
221+
222+
await _run_session(server_url, oauth_auth)
223+
224+
225+
async def run_client_credentials_basic_client(server_url: str) -> None:
226+
"""
227+
Run the conformance test client with client credentials flow using client_secret_basic.
228+
229+
This function:
230+
1. Connects to the MCP server with OAuth client_credentials grant
231+
2. Uses client_secret_basic authentication with credentials from MCP_CONFORMANCE_CONTEXT
232+
3. Initializes the session
233+
4. Lists available tools
234+
5. Calls a test tool
235+
"""
236+
logger.debug(f"Starting conformance auth client (client_credentials_basic) for {server_url}")
237+
238+
# Load credentials from environment
239+
context = get_conformance_context()
240+
client_id = context.get("client_id")
241+
client_secret = context.get("client_secret")
242+
243+
if not client_id:
244+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
245+
if not client_secret:
246+
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'")
247+
248+
# Create OAuth provider for client_credentials with client_secret_basic
249+
oauth_auth = ClientCredentialsOAuthProvider(
250+
server_url=server_url,
251+
storage=InMemoryTokenStorage(),
252+
client_id=client_id,
253+
client_secret=client_secret,
254+
token_endpoint_auth_method="client_secret_basic",
255+
)
256+
257+
await _run_session(server_url, oauth_auth)
258+
259+
260+
async def _run_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
261+
"""Common session logic for all OAuth flows."""
143262
# Connect using streamable HTTP transport with OAuth
144263
async with streamablehttp_client(
145264
url=server_url,
@@ -168,14 +287,26 @@ async def run_client(server_url: str) -> None:
168287

169288
def main() -> None:
170289
"""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)
290+
if len(sys.argv) != 3:
291+
print(f"Usage: {sys.argv[0]} <scenario> <server-url>", file=sys.stderr)
292+
print("", file=sys.stderr)
293+
print("Scenarios:", file=sys.stderr)
294+
print(" auth/* - Authorization code flow (default)", file=sys.stderr)
295+
print(" auth/client-credentials-jwt - Client credentials with JWT auth (SEP-1046)", file=sys.stderr)
296+
print(" auth/client-credentials-basic - Client credentials with client_secret_basic", file=sys.stderr)
173297
sys.exit(1)
174298

175-
server_url = sys.argv[1]
299+
scenario = sys.argv[1]
300+
server_url = sys.argv[2]
176301

177302
try:
178-
asyncio.run(run_client(server_url))
303+
if scenario == "auth/client-credentials-jwt":
304+
asyncio.run(run_client_credentials_jwt_client(server_url))
305+
elif scenario == "auth/client-credentials-basic":
306+
asyncio.run(run_client_credentials_basic_client(server_url))
307+
else:
308+
# Default to authorization code flow for all other auth/* scenarios
309+
asyncio.run(run_authorization_code_client(server_url))
179310
except Exception:
180311
logger.exception("Client failed")
181312
sys.exit(1)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# MCP SSE Polling Demo Client
2+
3+
Demonstrates client-side auto-reconnect for the SSE polling pattern (SEP-1699).
4+
5+
## Features
6+
7+
- Connects to SSE polling demo server
8+
- Automatically reconnects when server closes SSE stream
9+
- Resumes from Last-Event-ID to avoid missing messages
10+
- Respects server-provided retry interval
11+
12+
## Usage
13+
14+
```bash
15+
# First start the server:
16+
uv run mcp-sse-polling-demo --port 3000
17+
18+
# Then run this client:
19+
uv run mcp-sse-polling-client --url http://localhost:3000/mcp
20+
21+
# Custom options:
22+
uv run mcp-sse-polling-client --url http://localhost:3000/mcp --items 20 --checkpoint-every 5
23+
```
24+
25+
## Options
26+
27+
- `--url`: Server URL (default: <http://localhost:3000/mcp>)
28+
- `--items`: Number of items to process (default: 10)
29+
- `--checkpoint-every`: Checkpoint interval (default: 3)
30+
- `--log-level`: Logging level (default: DEBUG)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""SSE Polling Demo Client - demonstrates auto-reconnect for long-running tasks."""
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""
2+
SSE Polling Demo Client
3+
4+
Demonstrates the client-side auto-reconnect for SSE polling pattern.
5+
6+
This client connects to the SSE Polling Demo server and calls process_batch,
7+
which triggers periodic server-side stream closes. The client automatically
8+
reconnects using Last-Event-ID and resumes receiving messages.
9+
10+
Run with:
11+
# First start the server:
12+
uv run mcp-sse-polling-demo --port 3000
13+
14+
# Then run this client:
15+
uv run mcp-sse-polling-client --url http://localhost:3000/mcp
16+
"""
17+
18+
import asyncio
19+
import logging
20+
21+
import click
22+
from mcp import ClientSession
23+
from mcp.client.streamable_http import streamablehttp_client
24+
25+
logger = logging.getLogger(__name__)
26+
27+
28+
async def run_demo(url: str, items: int, checkpoint_every: int) -> None:
29+
"""Run the SSE polling demo."""
30+
print(f"\n{'=' * 60}")
31+
print("SSE Polling Demo Client")
32+
print(f"{'=' * 60}")
33+
print(f"Server URL: {url}")
34+
print(f"Processing {items} items with checkpoints every {checkpoint_every}")
35+
print(f"{'=' * 60}\n")
36+
37+
async with streamablehttp_client(url) as (read_stream, write_stream, _):
38+
async with ClientSession(read_stream, write_stream) as session:
39+
# Initialize the connection
40+
print("Initializing connection...")
41+
await session.initialize()
42+
print("Connected!\n")
43+
44+
# List available tools
45+
tools = await session.list_tools()
46+
print(f"Available tools: {[t.name for t in tools.tools]}\n")
47+
48+
# Call the process_batch tool
49+
print(f"Calling process_batch(items={items}, checkpoint_every={checkpoint_every})...\n")
50+
print("-" * 40)
51+
52+
result = await session.call_tool(
53+
"process_batch",
54+
{
55+
"items": items,
56+
"checkpoint_every": checkpoint_every,
57+
},
58+
)
59+
60+
print("-" * 40)
61+
if result.content:
62+
content = result.content[0]
63+
text = getattr(content, "text", str(content))
64+
print(f"\nResult: {text}")
65+
else:
66+
print("\nResult: No content")
67+
print(f"{'=' * 60}\n")
68+
69+
70+
@click.command()
71+
@click.option(
72+
"--url",
73+
default="http://localhost:3000/mcp",
74+
help="Server URL",
75+
)
76+
@click.option(
77+
"--items",
78+
default=10,
79+
help="Number of items to process",
80+
)
81+
@click.option(
82+
"--checkpoint-every",
83+
default=3,
84+
help="Checkpoint interval",
85+
)
86+
@click.option(
87+
"--log-level",
88+
default="INFO",
89+
help="Logging level",
90+
)
91+
def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None:
92+
"""Run the SSE Polling Demo client."""
93+
logging.basicConfig(
94+
level=getattr(logging, log_level.upper()),
95+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
96+
)
97+
# Suppress noisy HTTP client logging
98+
logging.getLogger("httpx").setLevel(logging.WARNING)
99+
logging.getLogger("httpcore").setLevel(logging.WARNING)
100+
101+
asyncio.run(run_demo(url, items, checkpoint_every))
102+
103+
104+
if __name__ == "__main__":
105+
main()
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[project]
2+
name = "mcp-sse-polling-client"
3+
version = "0.1.0"
4+
description = "Demo client for SSE polling with auto-reconnect"
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
authors = [{ name = "Anthropic, PBC." }]
8+
keywords = ["mcp", "sse", "polling", "client"]
9+
license = { text = "MIT" }
10+
dependencies = ["click>=8.2.0", "mcp"]
11+
12+
[project.scripts]
13+
mcp-sse-polling-client = "mcp_sse_polling_client.main:main"
14+
15+
[build-system]
16+
requires = ["hatchling"]
17+
build-backend = "hatchling.build"
18+
19+
[tool.hatch.build.targets.wheel]
20+
packages = ["mcp_sse_polling_client"]
21+
22+
[tool.pyright]
23+
include = ["mcp_sse_polling_client"]
24+
venvPath = "."
25+
venv = ".venv"
26+
27+
[tool.ruff.lint]
28+
select = ["E", "F", "I"]
29+
ignore = []
30+
31+
[tool.ruff]
32+
line-length = 120
33+
target-version = "py310"
34+
35+
[dependency-groups]
36+
dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"]

0 commit comments

Comments
 (0)