Authenticate MCP token requests one way under Basic auth

With client_secret_basic the MCP SDK sets the Basic header and still sends
client_id in the body; Linear refuses that as two authentication methods.
Token and refresh requests now drop the body client_id when the Basic
header is set, as RFC 6749 describes.
This commit is contained in:
arc53-machine committed 2026-09-28 23:43:49 +01:00
1 parent 0d42916d67
commit 33f29cb408
2 files changed
+62 -1

No files matched your search

+28 -1
View File
@@ -4,7 +4,7 @@ import concurrent.futures
import json
import logging
import time
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import parse_qs, urlparse
from fastmcp import Client
@@ -771,10 +771,37 @@ class DocsGPTOAuth(OAuthClientProvider):
redirect_handler=self.redirect_handler,
callback_handler=self.callback_handler,
)
self.context.prepare_token_auth = self._one_client_authentication(self.context.prepare_token_auth)
self.auth_url = None
self.extracted_state = None
@staticmethod
def _one_client_authentication(prepare: Callable) -> Callable:
"""Wrap the SDK's token-request auth so the client authenticates one way.
With ``client_secret_basic`` the SDK sets the Basic header but leaves
``client_id`` in the body, which servers such as Linear reject as a
second method; RFC 6749 names the client in the body only when it does
not authenticate otherwise.
Args:
prepare: The SDK context's ``prepare_token_auth``.
Returns:
The same function, minus ``client_id`` in the body under Basic auth.
"""
def prepare_token_auth(
data: dict[str, str], headers: dict[str, str] | None = None
) -> tuple[dict[str, str], dict[str, str]]:
data, headers = prepare(data, headers)
if headers.get("Authorization", "").startswith("Basic "):
data = {k: v for k, v in data.items() if k != "client_id"}
return data, headers
return prepare_token_auth
def _process_auth_url(self, authorization_url: str) -> tuple[str, str]:
"""Process authorization URL to extract state"""
try:
+34
View File
@@ -172,3 +172,37 @@ def test_callback_hands_the_sdk_an_authorization_result():
assert isinstance(result, AuthorizationCodeResult)
assert (result.code, result.state, result.iss) == ("the-code", "st", "https://mcp.linear.app")
redis_client.delete.assert_any_call("mcp_oauth:iss:st")
@pytest.mark.parametrize(
"method, basic, body_client_id",
[("client_secret_basic", True, False), ("client_secret_post", False, True), ("none", False, True)],
)
def test_token_request_uses_one_client_authentication(method, basic, body_client_id):
"""Linear registers clients for ``client_secret_basic`` and refuses a token
request that also names the client in the body; RFC 6749 wants one method."""
from mcp.shared.auth import OAuthClientInformationFull
from docsgpt.agents.tools.mcp_tool import DocsGPTOAuth
oauth = DocsGPTOAuth(
mcp_url="https://mcp.linear.app/mcp",
redis_client=MagicMock(),
redirect_uri="https://example.com/callback",
user_id="user1",
)
oauth.context.client_info = OAuthClientInformationFull(
client_id="cid",
client_secret=None if method == "none" else "secret",
token_endpoint_auth_method=method,
redirect_uris=["https://example.com/callback"],
)
data, headers = oauth.context.prepare_token_auth(
{"grant_type": "authorization_code", "code": "c", "client_id": "cid"},
{"Content-Type": "application/x-www-form-urlencoded"},
)
assert headers.get("Authorization", "").startswith("Basic ") is basic
assert ("client_id" in data) is body_client_id
assert data["code"] == "c"