Skip to content

Commit e168d3e

Browse files
authored
fix(proxy): unpinned upstream servers pooled behind one shared HTTP client (#587)
* Update sbom.yml * initial commit Signed-off-by: rajnisht7 <rajnishtiwari9787@gmail.com> * remove empty line --------- Signed-off-by: rajnisht7 <rajnishtiwari9787@gmail.com>
1 parent d9fc737 commit e168d3e

3 files changed

Lines changed: 81 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
### Fixed
1818

19+
**`CMCPProxy._client_for_upstream` pooled every unpinned upstream server in a session behind one shared `httpx.AsyncClient` (#281).** The pinned branch already keys its cached client on the fingerprint (a matching pin *is* the same verified peer, so sharing there is correct), but both unpinned branches plain `http://` and the `PLACEHOLDER_FINGERPRINT` dev-mode pin collapsed to the single literal key `"unpinned"`, regardless of which server the entry actually pointed at. A gateway session whose catalog lists two or more unrelated unpinned upstreams (the supported, if discouraged, dev/demo path this same method's docstring describes) got one `httpx.AsyncClient` for all of them: one cookie jar, and one shared pool of `max_connections`/`max_keepalive_connections`, across servers whose only thing in common was that neither presented a real pin.
20+
1921
- **`POST /mcp` `tools/call` 500'd on a non-string `name`, and silently accepted a non-object `arguments`.** `_handle_tool_call` read `tool_name: str = params.get("name", "").lower()` and `arguments: dict[str, Any] = params.get("arguments", {})`: the `.get(field, default)` default only covers a genuinely *absent* field, so a caller-supplied `name` that is present but not a string (an int, a list, a bool, `null`) reached `.lower()` and raised an unhandled `AttributeError`, caught only by the outermost `_unhandled_error_handler` and logged as `UNHANDLED_EXCEPTION`/`INTERNAL_ERROR` for what is ordinary client input validation, not an internal failure. `arguments` had the matching gap on the other side: `_arg_shape_violation` (the #518/#562 depth/key-count/string-length gate) only recognizes `dict`, `list` and `str`, so a scalar `arguments` (an int, for instance) silently returned "no violation" and reached `call_tool` with a shape its own type annotation says cannot occur.
2022

2123
Both fields are exactly as caller-controlled as `_cmcp` a few lines below, already guarded with "A malformed `_cmcp` (string, list, number) must not 500 the call path" `name` and `arguments` were the two places that same reasoning wasn't applied. Both now return the same `-32602 Invalid params` JSON-RPC error the adjacent depth/key/string-length and non-dict-`params` checks already return, before `call_tool` is ever reached.

‎src/cmcp_runtime/mcp/proxy.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,24 +317,38 @@ def _client_for_upstream(self, entry: CatalogEntry) -> httpx.AsyncClient:
317317
cannot be compared must never silently degrade to unpinned.
318318
- http: pinning is impossible without TLS; warn once per server
319319
(dev/demo only) and proceed.
320+
321+
Every branch keys the cached client on ``_server_execution_key(entry)``
322+
(the same "security-relevant identity used to pool one upstream" that
323+
``_stdio_for`` keys its spawned children on), not only the pinned
324+
branch. A cache keyed on the literal string ``"unpinned"`` would give
325+
every plain-http and placeholder-pinned server in this session's
326+
catalog the *same* ``httpx.AsyncClient`` - and therefore the same
327+
cookie jar and connection-pool limits - regardless of how unrelated
328+
those upstreams are. Two catalog entries that happen to share one real
329+
pinned fingerprint correctly share a client: a matching pin *is* the
330+
same verified peer. Two that merely share the fact that neither is
331+
pinned are not the same peer, and must not share state meant to be
332+
scoped to one.
320333
"""
321334
server_url = entry.server.url
322335
fingerprint = entry.server.tls_fingerprint
323336
scheme = httpx.URL(server_url).scheme.lower()
337+
identity = _server_execution_key(entry)
324338
if scheme != "https":
325339
self._warn_pin_unenforced(
326340
server_url,
327341
"upstream is not https, TLS fingerprint pinning is impossible - "
328342
"plain-http upstreams are for local dev/demo only",
329343
)
330-
key = "unpinned"
344+
key = f"unpinned:{identity}"
331345
elif fingerprint == tls_pinning.PLACEHOLDER_FINGERPRINT:
332346
self._warn_pin_unenforced(
333347
server_url,
334348
"catalog tls_fingerprint is the unpinned-dev placeholder - peer "
335349
"identity is verified by CA trust only, not pinned to the catalog",
336350
)
337-
key = "unpinned"
351+
key = f"unpinned:{identity}"
338352
elif not tls_pinning.FINGERPRINT_PATTERN.match(fingerprint):
339353
raise UpstreamUnavailable(
340354
f"Catalog tls_fingerprint for {server_url} is malformed - refusing to connect",
@@ -346,7 +360,7 @@ def _client_for_upstream(self, entry: CatalogEntry) -> httpx.AsyncClient:
346360
client = self._http_clients.get(key)
347361
if client is None:
348362
timeout = httpx.Timeout(30.0)
349-
if key == "unpinned":
363+
if key.startswith("unpinned:"):
350364
client = httpx.AsyncClient(
351365
timeout=timeout, verify=tls_pinning.default_ssl_context()
352366
)

‎tests/unit/test_mcp_proxy.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,68 @@ def test_server_cache_keys_bind_actual_identity_not_display_label():
6262
assert _server_provenance_key(first) != _server_provenance_key(second)
6363

6464

65+
def _make_unpinned_entry(tool_name: str, url: str) -> CatalogEntry:
66+
"""An entry with no real TLS pin -- the placeholder, dev-mode value."""
67+
from cmcp_runtime.mcp import tls_pinning
68+
69+
entry = _make_entry(tool_name)
70+
entry.server.url = url
71+
entry.server.tls_fingerprint = tls_pinning.PLACEHOLDER_FINGERPRINT
72+
return entry
73+
74+
75+
def test_unpinned_upstreams_do_not_share_an_http_client():
76+
"""Two distinct, unpinned upstream servers must not be pooled behind the
77+
same httpx.AsyncClient: that client carries a cookie jar and connection
78+
limits, and two catalog entries that merely agree on having no real pin
79+
are not the same peer. _server_execution_key already exists for exactly
80+
this ("the security-relevant identity used to pool one upstream") and
81+
_stdio_for already keys spawned children on it; _client_for_upstream must
82+
key on it too, not on the constant string "unpinned"."""
83+
entry_a = _make_unpinned_entry("a.tool", "https://server-a.example.com/mcp")
84+
entry_b = _make_unpinned_entry("b.tool", "https://server-b.example.com/mcp")
85+
catalog = ToolCatalog(
86+
entries={"a.tool": entry_a, "b.tool": entry_b}, catalog_hash="sha256:" + "1" * 64
87+
)
88+
proxy, _, _ = _make_proxy(catalog=catalog)
89+
90+
client_a = proxy._client_for_upstream(entry_a)
91+
client_b = proxy._client_for_upstream(entry_b)
92+
93+
assert client_a is not client_b
94+
assert client_a.cookies is not client_b.cookies
95+
96+
97+
def test_same_unpinned_upstream_still_reuses_its_client():
98+
"""Two tools on the *same* unpinned server correctly share one client -
99+
this is pooling by server identity, not a blanket per-tool client."""
100+
entry_a1 = _make_unpinned_entry("a1.tool", "https://server-a.example.com/mcp")
101+
entry_a2 = _make_unpinned_entry("a2.tool", "https://server-a.example.com/mcp")
102+
catalog = ToolCatalog(
103+
entries={"a1.tool": entry_a1, "a2.tool": entry_a2}, catalog_hash="sha256:" + "1" * 64
104+
)
105+
proxy, _, _ = _make_proxy(catalog=catalog)
106+
107+
assert proxy._client_for_upstream(entry_a1) is proxy._client_for_upstream(entry_a2)
108+
109+
110+
def test_different_servers_sharing_one_real_pin_still_share_a_client():
111+
"""The pinned branch is unchanged: a matching TLS fingerprint *is* the
112+
same verified peer, so sharing a client there is correct, not a leak."""
113+
entry_p1 = _make_entry("p1.tool")
114+
entry_p1.server.url = "https://pinned-1.example.com/mcp"
115+
entry_p1.server.tls_fingerprint = "SHA256:" + "A" * 43 + "B"
116+
entry_p2 = _make_entry("p2.tool")
117+
entry_p2.server.url = "https://pinned-2.example.com/mcp"
118+
entry_p2.server.tls_fingerprint = entry_p1.server.tls_fingerprint
119+
catalog = ToolCatalog(
120+
entries={"p1.tool": entry_p1, "p2.tool": entry_p2}, catalog_hash="sha256:" + "1" * 64
121+
)
122+
proxy, _, _ = _make_proxy(catalog=catalog)
123+
124+
assert proxy._client_for_upstream(entry_p1) is proxy._client_for_upstream(entry_p2)
125+
126+
65127
def test_same_server_identity_is_reused_across_tools():
66128
first = _make_entry("first.tool")
67129
second = _make_entry("second.tool")

0 commit comments

Comments
 (0)