Skip to content

Commit 990a5d8

Browse files
committed
fix(http): auto-extract caller User-Agent in get_client()
Tool handlers call get_client() directly from utils without passing user_agent, bypassing the MCP server's _get_caller_user_agent() method. This caused all tool-initiated API calls to use the default "GitGuardian-MCP-Server/<version>" user-agent instead of forwarding the caller's (e.g. "GitGuardian-In-App-Agent"). Move user-agent extraction into utils.get_client() so all callers benefit automatically. Issue: #
1 parent 4f4dd14 commit 990a5d8

File tree

4 files changed

+149
-18
lines changed

4 files changed

+149
-18
lines changed

packages/gg_api_core/src/gg_api_core/mcp_server.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -160,22 +160,9 @@ async def get_token_info(self) -> dict[str, Any]:
160160
"""Return the token info dictionary."""
161161
pass
162162

163-
def _get_caller_user_agent(self) -> str | None:
164-
"""Try to extract User-Agent from the incoming MCP request headers.
165-
166-
Returns None if not available (e.g. stdio transport).
167-
"""
168-
try:
169-
headers = get_http_headers(include={"user-agent"})
170-
return headers.get("user-agent") if headers else None
171-
except Exception:
172-
logger.exception("Error when trying to extract User-Agent from headers")
173-
return None
174-
175163
async def get_client(self) -> GitGuardianClient:
176164
return await get_client(
177165
personal_access_token=self.get_personal_access_token(),
178-
user_agent=self._get_caller_user_agent(),
179166
)
180167

181168
async def revoke_current_token(self) -> dict[str, Any]:

packages/gg_api_core/src/gg_api_core/oauth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,7 @@ async def redirect_handler(authorization_url: str) -> None:
688688
headers: dict[str, str] = {
689689
"Content-Type": "application/x-www-form-urlencoded",
690690
"X-Token-Name": str(self.token_name), # Custom header with token name
691-
"User-Agent": f"MCP-Server/{self.token_name}", # Include in user agent
691+
"User-Agent": f"GitGuardian-MCP-Server/{self.token_name}", # Include in user agent
692692
}
693693

694694
async with httpx.AsyncClient(follow_redirects=True) as client:

packages/gg_api_core/src/gg_api_core/utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ async def get_client(personal_access_token: str | None = None, user_agent: str |
6666
6767
Args:
6868
personal_access_token: Optional PAT for explicit authentication.
69+
user_agent: Optional User-Agent string. If not provided, automatically
70+
extracted from incoming HTTP request headers (when available).
6971
7072
Returns:
7173
GitGuardianClient: Client instance configured with appropriate authentication
@@ -74,6 +76,10 @@ async def get_client(personal_access_token: str | None = None, user_agent: str |
7476
ValidationError: In multi-tenant mode, if MCP_PORT not set or Authorization header missing
7577
RuntimeError: In single-tenant mode, if no token source is available
7678
"""
79+
# Extract caller User-Agent from HTTP headers if not explicitly provided
80+
if user_agent is None:
81+
user_agent = _get_caller_user_agent()
82+
7783
# 1. Explicit PAT provided - caller manages the token (no caching, no automatic refresh)
7884
if personal_access_token:
7985
logger.debug("Creating client with explicitly provided token")
@@ -107,6 +113,19 @@ async def get_client(personal_access_token: str | None = None, user_agent: str |
107113
return _client_singleton
108114

109115

116+
def _get_caller_user_agent() -> str | None:
117+
"""Try to extract User-Agent from the incoming MCP request headers.
118+
119+
Returns None if not available (e.g. stdio transport).
120+
"""
121+
try:
122+
headers = get_http_headers(include={"user-agent"})
123+
return headers.get("user-agent") if headers else None
124+
except Exception:
125+
logger.exception("Error when trying to extract User-Agent from headers")
126+
return None
127+
128+
110129
def _get_token_from_request_headers() -> str:
111130
"""Extract personal access token from HTTP request headers.
112131

tests/test_get_client_safeguards.py

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import pytest
77
from fastmcp.exceptions import ValidationError
8-
from gg_api_core.utils import get_client, get_mcp_port_or_none, is_multi_tenant_mode
8+
from gg_api_core.utils import _get_caller_user_agent, get_client, get_mcp_port_or_none, is_multi_tenant_mode
99

1010

1111
class TestGetMcpPortOrNone:
@@ -128,7 +128,7 @@ async def test_multi_tenant_extracts_token_from_headers(self, mock_client_class,
128128
GIVEN MULTI_TENANCY_ENABLED=true and MCP_PORT is set
129129
AND Authorization header is present
130130
WHEN get_client is called
131-
THEN it extracts token from headers and creates new client
131+
THEN it extracts token and user-agent from headers and creates new client
132132
"""
133133
mock_get_headers.return_value = {"authorization": "Bearer request-token"}
134134
mock_client = MagicMock()
@@ -140,6 +140,31 @@ async def test_multi_tenant_extracts_token_from_headers(self, mock_client_class,
140140
mock_client_class.assert_called_once_with(personal_access_token="request-token", user_agent=None)
141141
assert result == mock_client
142142

143+
@patch("gg_api_core.utils.get_http_headers")
144+
@patch("gg_api_core.utils.GitGuardianClient")
145+
async def test_multi_tenant_forwards_caller_user_agent(self, mock_client_class, mock_get_headers):
146+
"""
147+
GIVEN MULTI_TENANCY_ENABLED=true and MCP_PORT is set
148+
AND request has both Authorization and User-Agent headers
149+
WHEN get_client is called
150+
THEN the caller's User-Agent is forwarded to GitGuardianClient
151+
"""
152+
mock_get_headers.return_value = {
153+
"authorization": "Bearer request-token",
154+
"user-agent": "GitGuardian-In-App-Agent",
155+
}
156+
mock_client = MagicMock()
157+
mock_client_class.return_value = mock_client
158+
159+
with patch.dict(os.environ, {"MULTI_TENANCY_ENABLED": "true", "MCP_PORT": "8080"}, clear=True):
160+
result = await get_client()
161+
162+
mock_client_class.assert_called_once_with(
163+
personal_access_token="request-token",
164+
user_agent="GitGuardian-In-App-Agent",
165+
)
166+
assert result == mock_client
167+
143168
@patch("gg_api_core.utils.get_http_headers")
144169
@patch("gg_api_core.utils.GitGuardianClient")
145170
async def test_multi_tenant_creates_new_client_per_request(self, mock_client_class, mock_get_headers):
@@ -148,9 +173,13 @@ async def test_multi_tenant_creates_new_client_per_request(self, mock_client_cla
148173
WHEN get_client is called multiple times with different tokens
149174
THEN it creates a new client each time (no singleton)
150175
"""
176+
# get_http_headers is called twice per get_client():
177+
# once for user-agent extraction, once for authorization extraction
151178
mock_get_headers.side_effect = [
152-
{"authorization": "Bearer token1"},
153-
{"authorization": "Bearer token2"},
179+
{"authorization": "Bearer token1", "user-agent": "Agent1"},
180+
{"authorization": "Bearer token1", "user-agent": "Agent1"},
181+
{"authorization": "Bearer token2", "user-agent": "Agent2"},
182+
{"authorization": "Bearer token2", "user-agent": "Agent2"},
154183
]
155184
mock_client1 = MagicMock()
156185
mock_client2 = MagicMock()
@@ -338,3 +367,99 @@ async def test_multi_tenant_never_uses_singleton(self, mock_client_class, mock_g
338367
assert mock_client_class.call_count == 3
339368
# Singleton should remain None
340369
assert gg_api_core.utils._client_singleton is None
370+
371+
372+
class TestCallerUserAgentExtraction:
373+
"""Tests for _get_caller_user_agent() and automatic user-agent forwarding."""
374+
375+
@patch("gg_api_core.utils.get_http_headers")
376+
def test_extracts_user_agent_from_headers(self, mock_get_headers):
377+
"""
378+
GIVEN an HTTP request with a User-Agent header
379+
WHEN _get_caller_user_agent is called
380+
THEN it returns the user-agent string
381+
"""
382+
mock_get_headers.return_value = {"user-agent": "GitGuardian-In-App-Agent"}
383+
384+
result = _get_caller_user_agent()
385+
386+
assert result == "GitGuardian-In-App-Agent"
387+
388+
@patch("gg_api_core.utils.get_http_headers")
389+
def test_returns_none_when_no_user_agent(self, mock_get_headers):
390+
"""
391+
GIVEN an HTTP request without a User-Agent header
392+
WHEN _get_caller_user_agent is called
393+
THEN it returns None
394+
"""
395+
mock_get_headers.return_value = {"authorization": "Bearer token"}
396+
397+
result = _get_caller_user_agent()
398+
399+
assert result is None
400+
401+
@patch("gg_api_core.utils.get_http_headers")
402+
def test_returns_none_when_no_http_context(self, mock_get_headers):
403+
"""
404+
GIVEN no active HTTP request (e.g. stdio transport)
405+
WHEN _get_caller_user_agent is called
406+
THEN it returns None
407+
"""
408+
mock_get_headers.return_value = {}
409+
410+
result = _get_caller_user_agent()
411+
412+
assert result is None
413+
414+
@patch("gg_api_core.utils.get_http_headers")
415+
def test_returns_none_on_exception(self, mock_get_headers):
416+
"""
417+
GIVEN get_http_headers raises an exception
418+
WHEN _get_caller_user_agent is called
419+
THEN it returns None (graceful degradation)
420+
"""
421+
mock_get_headers.side_effect = RuntimeError("Unexpected error")
422+
423+
result = _get_caller_user_agent()
424+
425+
assert result is None
426+
427+
@patch("gg_api_core.utils.get_http_headers")
428+
@patch("gg_api_core.utils.GitGuardianClient")
429+
async def test_get_client_auto_extracts_user_agent(self, mock_client_class, mock_get_headers):
430+
"""
431+
GIVEN an HTTP request with User-Agent header
432+
WHEN get_client() is called without explicit user_agent (as tool handlers do)
433+
THEN the caller's User-Agent is automatically forwarded to GitGuardianClient
434+
"""
435+
mock_get_headers.return_value = {
436+
"authorization": "Bearer request-token",
437+
"user-agent": "GitGuardian-In-App-Agent",
438+
}
439+
mock_client_class.return_value = MagicMock()
440+
441+
with patch.dict(os.environ, {"MULTI_TENANCY_ENABLED": "true", "MCP_PORT": "8080"}, clear=True):
442+
await get_client()
443+
444+
mock_client_class.assert_called_once_with(
445+
personal_access_token="request-token",
446+
user_agent="GitGuardian-In-App-Agent",
447+
)
448+
449+
@patch("gg_api_core.utils.get_http_headers")
450+
@patch("gg_api_core.utils.GitGuardianClient")
451+
async def test_explicit_user_agent_takes_precedence(self, mock_client_class, mock_get_headers):
452+
"""
453+
GIVEN an HTTP request with User-Agent header
454+
WHEN get_client() is called with an explicit user_agent
455+
THEN the explicit user_agent is used, not the one from headers
456+
"""
457+
mock_get_headers.return_value = {"user-agent": "GitGuardian-In-App-Agent"}
458+
mock_client_class.return_value = MagicMock()
459+
460+
await get_client(personal_access_token="explicit-token", user_agent="Custom-Agent")
461+
462+
mock_client_class.assert_called_once_with(
463+
personal_access_token="explicit-token",
464+
user_agent="Custom-Agent",
465+
)

0 commit comments

Comments
 (0)