diff --git a/docsgpt/agents/tools/mcp_tool.py b/docsgpt/agents/tools/mcp_tool.py index 23d77d72..7b34fa19 100644 --- a/docsgpt/agents/tools/mcp_tool.py +++ b/docsgpt/agents/tools/mcp_tool.py @@ -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: diff --git a/tests/connectors/test_mcp_refresh.py b/tests/connectors/test_mcp_refresh.py index 50f21edc..f03be8a0 100644 --- a/tests/connectors/test_mcp_refresh.py +++ b/tests/connectors/test_mcp_refresh.py @@ -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"