Skip to content

Commit 0f4b001

Browse files
committed
Support client_secret_basic in client_credentials flow
Extend RFC7523OAuthClientProvider._exchange_token_client_credentials() to support both private_key_jwt (with JWT assertion) and client_secret_basic (with HTTP Basic auth) authentication methods for the client_credentials grant type. The method now checks token_endpoint_auth_method: - private_key_jwt: Adds JWT client_assertion (requires jwt_parameters) - client_secret_basic/client_secret_post/none: Uses context.prepare_token_auth() Also add a conformance client scenario for auth/client-credentials-basic that uses static well-known test credentials.
1 parent e8bcd85 commit 0f4b001

File tree

2 files changed

+73
-17
lines changed

2 files changed

+73
-17
lines changed

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

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
python -m mcp_conformance_auth_client <scenario> <server-url>
1111
1212
Scenarios:
13-
auth/* - Authorization code flow scenarios (default behavior)
14-
auth/client-credentials-jwt - Client credentials with JWT authentication (SEP-1046)
13+
auth/* - Authorization code flow scenarios (default behavior)
14+
auth/client-credentials-jwt - Client credentials with JWT authentication (SEP-1046)
15+
auth/client-credentials-basic - Client credentials with client_secret_basic
1516
"""
1617

1718
import asyncio
@@ -40,6 +41,9 @@
4041
# The client_id expected by the conformance test
4142
CONFORMANCE_TEST_CLIENT_ID = "conformance-test-client"
4243

44+
# Static credentials for client_secret_basic flow
45+
CONFORMANCE_CLIENT_SECRET = "conformance-test-secret"
46+
4347
# Set up logging to stderr (stdout is for conformance test output)
4448
logging.basicConfig(
4549
level=logging.DEBUG,
@@ -160,9 +164,9 @@ async def run_authorization_code_client(server_url: str) -> None:
160164
await _run_session(server_url, oauth_auth)
161165

162166

163-
async def run_client_credentials_client(server_url: str) -> None:
167+
async def run_client_credentials_jwt_client(server_url: str) -> None:
164168
"""
165-
Run the conformance test client with client credentials flow (SEP-1046).
169+
Run the conformance test client with client credentials flow using private_key_jwt (SEP-1046).
166170
167171
This function:
168172
1. Connects to the MCP server with OAuth client_credentials grant
@@ -171,7 +175,7 @@ async def run_client_credentials_client(server_url: str) -> None:
171175
4. Lists available tools
172176
5. Calls a test tool
173177
"""
174-
logger.debug(f"Starting conformance auth client (client_credentials) for {server_url}")
178+
logger.debug(f"Starting conformance auth client (client_credentials_jwt) for {server_url}")
175179

176180
# Create JWT parameters for private_key_jwt authentication
177181
jwt_params = JWTParameters(
@@ -199,6 +203,46 @@ async def run_client_credentials_client(server_url: str) -> None:
199203
await _run_session(server_url, oauth_auth)
200204

201205

206+
async def run_client_credentials_basic_client(server_url: str) -> None:
207+
"""
208+
Run the conformance test client with client credentials flow using client_secret_basic.
209+
210+
This function:
211+
1. Connects to the MCP server with OAuth client_credentials grant
212+
2. Uses client_secret_basic authentication with static credentials
213+
3. Initializes the session
214+
4. Lists available tools
215+
5. Calls a test tool
216+
"""
217+
logger.debug(f"Starting conformance auth client (client_credentials_basic) for {server_url}")
218+
219+
# Create storage pre-populated with static client credentials
220+
storage = InMemoryTokenStorage()
221+
await storage.set_client_info(
222+
OAuthClientInformationFull(
223+
client_id=CONFORMANCE_TEST_CLIENT_ID,
224+
client_secret=CONFORMANCE_CLIENT_SECRET,
225+
redirect_uris=[AnyUrl("http://localhost:0/unused")],
226+
token_endpoint_auth_method="client_secret_basic",
227+
)
228+
)
229+
230+
# Create OAuth authentication handler for client_credentials flow with basic auth
231+
oauth_auth = RFC7523OAuthClientProvider(
232+
server_url=server_url,
233+
client_metadata=OAuthClientMetadata(
234+
client_name=CONFORMANCE_TEST_CLIENT_ID,
235+
redirect_uris=[AnyUrl("http://localhost:0/unused")], # Required but unused
236+
grant_types=["client_credentials"],
237+
response_types=[],
238+
token_endpoint_auth_method="client_secret_basic",
239+
),
240+
storage=storage,
241+
)
242+
243+
await _run_session(server_url, oauth_auth)
244+
245+
202246
async def _run_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
203247
"""Common session logic for all OAuth flows."""
204248
# Connect using streamable HTTP transport with OAuth
@@ -233,16 +277,19 @@ def main() -> None:
233277
print(f"Usage: {sys.argv[0]} <scenario> <server-url>", file=sys.stderr)
234278
print("", file=sys.stderr)
235279
print("Scenarios:", file=sys.stderr)
236-
print(" auth/* - Authorization code flow (default)", file=sys.stderr)
237-
print(" auth/client-credentials-jwt - Client credentials with JWT auth (SEP-1046)", file=sys.stderr)
280+
print(" auth/* - Authorization code flow (default)", file=sys.stderr)
281+
print(" auth/client-credentials-jwt - Client credentials with JWT auth (SEP-1046)", file=sys.stderr)
282+
print(" auth/client-credentials-basic - Client credentials with client_secret_basic", file=sys.stderr)
238283
sys.exit(1)
239284

240285
scenario = sys.argv[1]
241286
server_url = sys.argv[2]
242287

243288
try:
244289
if scenario == "auth/client-credentials-jwt":
245-
asyncio.run(run_client_credentials_client(server_url))
290+
asyncio.run(run_client_credentials_jwt_client(server_url))
291+
elif scenario == "auth/client-credentials-basic":
292+
asyncio.run(run_client_credentials_basic_client(server_url))
246293
else:
247294
# Default to authorization code flow for all other auth/* scenarios
248295
asyncio.run(run_authorization_code_client(server_url))

src/mcp/client/auth/extensions/client_credentials.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,24 +123,35 @@ def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]): # prag
123123
token_data["audience"] = self.context.get_resource_url()
124124

125125
async def _exchange_token_client_credentials(self) -> httpx.Request:
126-
"""Build token exchange request for client_credentials grant with private_key_jwt auth.
126+
"""Build token exchange request for client_credentials grant.
127127
128128
This implements SEP-1046: OAuth Client Credentials Extension for MCP.
129-
Uses RFC 7523 Section 2.2 for client authentication via JWT assertion.
129+
Supports both:
130+
- private_key_jwt: Uses RFC 7523 Section 2.2 for client authentication via JWT assertion
131+
- client_secret_basic: Uses HTTP Basic auth with client_id:client_secret
130132
"""
131133
if not self.context.client_info:
132134
raise OAuthFlowError("Missing client info") # pragma: no cover
133-
if not self.jwt_parameters:
134-
raise OAuthFlowError("Missing JWT parameters for client_credentials flow") # pragma: no cover
135135
if not self.context.oauth_metadata:
136136
raise OAuthTokenError("Missing OAuth metadata") # pragma: no cover
137137

138138
token_data: dict[str, Any] = {
139139
"grant_type": "client_credentials",
140140
}
141141

142-
# Add JWT client authentication (RFC 7523 Section 2.2)
143-
self._add_client_authentication_jwt(token_data=token_data)
142+
headers: dict[str, str] = {"Content-Type": "application/x-www-form-urlencoded"}
143+
144+
# Add client authentication based on auth method
145+
auth_method = self.context.client_metadata.token_endpoint_auth_method
146+
if auth_method == "private_key_jwt":
147+
# Add JWT client authentication (RFC 7523 Section 2.2)
148+
if not self.jwt_parameters:
149+
raise OAuthFlowError("Missing JWT parameters for private_key_jwt flow") # pragma: no cover
150+
self._add_client_authentication_jwt(token_data=token_data)
151+
else:
152+
# Use standard auth methods (client_secret_basic, client_secret_post, none)
153+
# via the context's prepare_token_auth helper
154+
token_data, headers = self.context.prepare_token_auth(token_data, headers)
144155

145156
if self.context.should_include_resource_param(self.context.protocol_version): # pragma: no branch
146157
token_data["resource"] = self.context.get_resource_url()
@@ -149,9 +160,7 @@ async def _exchange_token_client_credentials(self) -> httpx.Request:
149160
token_data["scope"] = self.context.client_metadata.scope
150161

151162
token_url = self._get_token_endpoint()
152-
return httpx.Request(
153-
"POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
154-
)
163+
return httpx.Request("POST", token_url, data=token_data, headers=headers)
155164

156165
async def _exchange_token_jwt_bearer(self) -> httpx.Request:
157166
"""Build token exchange request for JWT bearer grant (RFC 7523 Section 2.1).

0 commit comments

Comments
 (0)