Skip to content

Commit 1aa0037

Browse files
committed
Updates to address comments
1 parent aae4a99 commit 1aa0037

3 files changed

Lines changed: 56 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
- **Missing Server and Gateway Deletes** - `DELETE /servers/{id}` and `DELETE /gateways/{id}` now return `404 Not Found` when the target no longer exists, instead of incorrectly returning `403 Forbidden`.
1414
- **Custom Auth Headers on Tools** ([#5314](https://github.com/IBM/mcp-context-forge/pull/5314), [#5201](https://github.com/IBM/mcp-context-forge/issues/5201)) - `POST /tools` and `PUT /tools/{tool_id}` now persist the `auth_headers` array instead of silently storing `auth_value: null`. Invalid header keys/values are rejected with a 422 rather than an unhandled 500.
1515

16+
- **Redis Connection Leak** ([#5711](https://github.com/IBM/mcp-context-forge/pull/5711)) - The `mcpgateway/plugins/__init__.py` had a connection leak in `_plugin_invalidation_listener`. The pubsub client is unsubscribed but the connection was never closed, eventually leading to connection pool exhaustion.
17+
1618
### Changed
1719

1820
- **Stricter `authheaders` Key Validation (Gateways, Tools, A2A Agents)** ([#5314](https://github.com/IBM/mcp-context-forge/pull/5314)) - Header-key validation is now shared across all create/update schemas and the admin form. Keys with embedded whitespace (e.g. `X Api Key`) were previously accepted and stored as invalid HTTP header names that failed at invocation time; they are now rejected with a 422 at config time, and surrounding whitespace is trimmed before storage. Gateway or A2A configs relying on the old behavior will need their header keys corrected on the next update.

mcpgateway/plugins/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,10 +528,13 @@ async def _plugin_invalidation_listener() -> None:
528528
backoff = min(max_backoff, backoff * 2)
529529
finally:
530530
# Always release the pubsub connection back to the pool.
531-
# Using wait_for shields aclose() from an in-flight CancelledError:
531+
# wait_for bounds aclose() so a stalled redis-py connection cannot
532+
# block the finally clause indefinitely.
532533
if pubsub is not None:
533534
try:
534535
await asyncio.wait_for(pubsub.aclose(), timeout=2.0)
536+
except asyncio.TimeoutError:
537+
_logger.debug("Plugin invalidation listener: pubsub aclose timed out")
535538
except Exception as exc:
536539
_logger.error("Plugin invalidation listener: pubsub aclose failed (%s)", exc)
537540

tests/unit/mcpgateway/plugins/test_plugin_runtime_management.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1816,6 +1816,56 @@ async def _fake_sleep(_delay):
18161816
# aclose() must have been awaited once before the backoff sleep.
18171817
pubsub.aclose.assert_awaited_once()
18181818

1819+
@pytest.mark.asyncio
1820+
async def test_pubsub_aclose_timeout_guard_prevents_hang(self, monkeypatch):
1821+
"""``wait_for(aclose(), timeout=2.0)`` lets the listener exit even when aclose() hangs.
1822+
1823+
Regression guard for the timeout introduced to bound a hanging aclose():
1824+
if the ``timeout=2.0`` parameter were removed (or the wait_for dropped),
1825+
the finally block would suspend indefinitely and the listener task would
1826+
never return.
1827+
1828+
The test replaces aclose() with a coroutine that sleeps for 999 s and
1829+
asserts the entire listener finishes well inside that window.
1830+
"""
1831+
import time
1832+
1833+
import mcpgateway.plugins as framework
1834+
1835+
async def _fake_handle(msg):
1836+
raise asyncio.CancelledError()
1837+
1838+
monkeypatch.setattr(framework, "_handle_invalidation_message", _fake_handle)
1839+
1840+
async def _hanging_aclose():
1841+
await asyncio.sleep(999)
1842+
1843+
pubsub = MagicMock()
1844+
pubsub.subscribe = AsyncMock()
1845+
pubsub.aclose = _hanging_aclose # not an AsyncMock — returns a real coroutine
1846+
1847+
class _Listen:
1848+
def __aiter__(self):
1849+
return self
1850+
1851+
async def __anext__(self):
1852+
return {"type": "message", "data": "{}"}
1853+
1854+
pubsub.listen = MagicMock(return_value=_Listen())
1855+
1856+
client = MagicMock()
1857+
client.pubsub = MagicMock(return_value=pubsub)
1858+
monkeypatch.setattr(framework, "_redis", AsyncMock(return_value=client))
1859+
1860+
start = time.monotonic()
1861+
await framework._plugin_invalidation_listener()
1862+
elapsed = time.monotonic() - start
1863+
1864+
# The listener must complete well before aclose()'s 999 s sleep expires.
1865+
# A generous upper bound of 5 s covers slow CI environments while still
1866+
# being far shorter than the hang duration.
1867+
assert elapsed < 5.0, f"Listener took {elapsed:.2f}s — timeout guard may be missing"
1868+
18191869

18201870
# ---------------------------------------------------------------------------
18211871
# HMAC signing and verification tests

0 commit comments

Comments
 (0)