Hand the MCP SDK an authorization result with the issuer

mcp 2.x reads code, state and the RFC 9207 iss off the callback handler's
result; the handler still returned a (code, state) tuple, so every MCP
sign-in failed after the user approved it. The callback route now keeps the
issuer too, since servers that advertise it (Linear) refuse a sign-in
without it.
This commit is contained in:
arc53-machine committed 2026-09-28 23:32:21 +01:00
1 parent cdaaaaf427
commit 0d42916d67
5 files changed
+97 -12

No files matched your search

+19 -5
View File
@@ -840,6 +840,15 @@ class TestMCPOAuthManager:
assert result is True
mock_redis.setex.assert_called()
def test_handle_callback_keeps_the_issuer(self):
from docsgpt.agents.tools.mcp_tool import MCPOAuthManager
mock_redis = MagicMock()
manager = MCPOAuthManager(mock_redis)
assert manager.handle_oauth_callback(state="s", code="c", iss="https://issuer.example") is True
mock_redis.setex.assert_any_call("mcp_oauth:iss:s", 300, "https://issuer.example")
def test_handle_callback_no_redis(self):
from docsgpt.agents.tools.mcp_tool import MCPOAuthManager
@@ -2026,8 +2035,11 @@ class TestDocsGPTOAuthExtended:
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
mock_redis = MagicMock()
# First get returns the code
mock_redis.get.return_value = b"auth_code_123"
stored = {
"mcp_oauth:code:mystate": b"auth_code_123",
"mcp_oauth:iss:mystate": b"https://auth.example.com",
}
mock_redis.get.side_effect = stored.get
oauth = DocsGPTOAuth(
mcp_url="https://mcp.example.com/api",
@@ -2043,9 +2055,11 @@ class TestDocsGPTOAuthExtended:
loop = asyncio.new_event_loop()
try:
code, state = loop.run_until_complete(oauth.callback_handler())
assert code == "auth_code_123"
assert state == "mystate"
# The MCP SDK reads ``.code``, ``.state`` and the RFC 9207 ``.iss``.
result = loop.run_until_complete(oauth.callback_handler())
assert result.code == "auth_code_123"
assert result.state == "mystate"
assert result.iss == "https://auth.example.com"
finally:
loop.close()
+20
View File
@@ -502,3 +502,23 @@ class TestMCPAuthStatus:
request.decoded_token = None
response = MCPAuthStatus().get()
assert response.status_code == 401
class TestMCPOAuthCallbackIssuer:
def test_passes_the_issuer_to_the_waiting_sign_in(self, app):
from docsgpt.api.user.tools.mcp import MCPOAuthCallback
manager = MagicMock()
manager.handle_oauth_callback.return_value = True
with patch(
"docsgpt.api.user.tools.mcp.get_redis_instance", return_value=MagicMock(),
), patch(
"docsgpt.api.user.tools.mcp.MCPOAuthManager", return_value=manager,
), app.test_request_context(
"/api/mcp_server/callback?code=c&state=s&iss=https%3A%2F%2Fmcp.linear.app",
):
response = MCPOAuthCallback().get()
assert response.status_code == 302
manager.handle_oauth_callback.assert_called_once_with(
"s", "c", None, iss="https://mcp.linear.app",
)
+30
View File
@@ -142,3 +142,33 @@ def test_oauth_resource_is_the_full_mcp_endpoint():
assert check_resource_allowed(requested_resource=requested, configured_resource="https://mcp.linear.app")
# Stored tokens stay keyed by the server's origin.
assert oauth.context.storage.server_url == "https://mcp.linear.app"
def test_callback_hands_the_sdk_an_authorization_result():
"""The MCP SDK reads ``.code``, ``.state`` and the RFC 9207 ``.iss`` off the
callback's result; Linear advertises ``iss`` and refuses a sign-in without it."""
import asyncio
from mcp.shared.auth import AuthorizationCodeResult
from docsgpt.agents.tools.mcp_tool import DocsGPTOAuth
stored = {
"mcp_oauth:code:st": b"the-code",
"mcp_oauth:iss:st": b"https://mcp.linear.app",
}
redis_client = MagicMock()
redis_client.get.side_effect = stored.get
oauth = DocsGPTOAuth(
mcp_url="https://mcp.linear.app/mcp",
redis_client=redis_client,
redirect_uri="https://example.com/callback",
user_id="user1",
)
oauth.extracted_state = "st"
result = asyncio.run(oauth.callback_handler())
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")