From 35d6d80e87fe5bc105a7c1c779fe9596472daa8f Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:11:19 +0100 Subject: [PATCH] Key cached MCP clients by a digest of the whole token The cache key held the first ten characters of a bearer token or API key. Every fine-grained GitHub token starts with github_pat_, so two users of the same server shared one cached client, and with it the first user's token, for up to five minutes. --- docsgpt/agents/tools/mcp_tool.py | 15 +++++++++++++-- tests/agents/tools/test_mcp_tool.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docsgpt/agents/tools/mcp_tool.py b/docsgpt/agents/tools/mcp_tool.py index 7b34fa19..6454c7d4 100644 --- a/docsgpt/agents/tools/mcp_tool.py +++ b/docsgpt/agents/tools/mcp_tool.py @@ -1,6 +1,7 @@ import asyncio import base64 import concurrent.futures +import hashlib import json import logging import time @@ -39,6 +40,16 @@ _mcp_clients_cache = {} _ANNOTATION_HINTS = ("readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint") +def _secret_fingerprint(secret: str) -> str: + """A short digest of a whole secret, for telling cached clients apart. + + A prefix of the secret itself is not enough: every fine-grained GitHub + token starts with ``github_pat_``, so two users' tokens would share (and + reuse) one cached client carrying the first user's token. + """ + return hashlib.sha256(secret.encode("utf-8")).hexdigest()[:16] + + def _annotation_hints(annotations: Any) -> Dict[str, bool]: """The boolean MCP tool annotation hints, from a model or a dict.""" if annotations is None: @@ -162,10 +173,10 @@ class MCPTool(Tool): token = self.auth_credentials.get( "bearer_token", "" ) or self.auth_credentials.get("access_token", "") - auth_key = f"bearer:{token[:10]}..." if token else "bearer:none" + auth_key = f"bearer:{_secret_fingerprint(token)}" if token else "bearer:none" elif self.auth_type == "api_key": api_key = self.auth_credentials.get("api_key", "") - auth_key = f"apikey:{api_key[:10]}..." if api_key else "apikey:none" + auth_key = f"apikey:{_secret_fingerprint(api_key)}" if api_key else "apikey:none" elif self.auth_type == "basic": username = self.auth_credentials.get("username", "") auth_key = f"basic:{username}" diff --git a/tests/agents/tools/test_mcp_tool.py b/tests/agents/tools/test_mcp_tool.py index 6104e28f..3dac7d1f 100644 --- a/tests/agents/tools/test_mcp_tool.py +++ b/tests/agents/tools/test_mcp_tool.py @@ -266,6 +266,23 @@ class TestGenerateCacheKey: }) assert "apikey:none" in tool._cache_key + @pytest.mark.parametrize("auth_type, field", [("bearer", "bearer_token"), ("api_key", "api_key")]) + def test_tokens_with_a_shared_prefix_get_their_own_client(self, auth_type, field): + """Every fine-grained GitHub token starts with ``github_pat_``; two users' tokens + must never share a cached client (it carries the first user's token).""" + first = _make_tool({ + "server_url": "https://mcp.example.com", + "auth_type": auth_type, + "auth_credentials": {field: "github_pat_11AAAAAAA_alice"}, + }) + second = _make_tool({ + "server_url": "https://mcp.example.com", + "auth_type": auth_type, + "auth_credentials": {field: "github_pat_11AAAAAAA_bob"}, + }) + assert first._cache_key != second._cache_key + assert "github_pat" not in first._cache_key + # ===================================================================== # Transport Creation