feat(plugins): runtime plugin management — global toggle, per-plugin mode, cross-instance propagation - #4292
Merged
Merged
Conversation
gandhipratik203
force-pushed
the
feat/test-plugin-runtime-management
branch
2 times, most recently
from
April 18, 2026 09:18
343743b to
4834af5
Compare
gandhipratik203
force-pushed
the
feat/test-plugin-runtime-management
branch
5 times, most recently
from
April 18, 2026 18:00
dfc121e to
eb2fdbc
Compare
gandhipratik203
marked this pull request as ready for review
April 18, 2026 19:15
gandhipratik203
requested review from
araujof,
brian-hussey,
crivetimihai,
jonpspri,
kevalmahajan,
madhav165 and
terylt
as code owners
April 18, 2026 19:15
gandhipratik203
force-pushed
the
feat/test-plugin-runtime-management
branch
4 times, most recently
from
April 19, 2026 07:15
6720ffd to
3af6de2
Compare
5 tasks
gandhipratik203
force-pushed
the
feat/test-plugin-runtime-management
branch
from
April 19, 2026 08:55
3af6de2 to
4ab097d
Compare
jonpspri
added a commit
that referenced
this pull request
Apr 19, 2026
Close the cross-instance propagation gaps, security test gaps, and framework-isolation violations found in PR #4292 review. Propagation correctness: - Wire start_plugin_invalidation_listener / stop_plugin_invalidation_listener into the FastAPI lifespan. Previously the listener was dead code and cross-pod updates relied entirely on the 30 s TTL. - Add publish_plugin_mode_change, publish_binding_change, and publish_team_binding_change helpers so admin and binding handlers broadcast on every mutation. Previously only the global toggle published. - Wildcard bindings (tool_name="*") now publish team_binding_change so every worker evicts every team_a::* cached context; the previous binding_change(context_id="team_a::*") would no-op on peer workers that had per-tool contexts cached. Applied to upsert, delete-by- reference, and single-UUID delete paths. - Narrow admin update_plugin_mode try/except to the Redis call so redis_persisted reflects the real outcome; broad failures now use logger.exception. Error handling: - _apply_redis_mode_overrides logs at WARNING with exc_info and validates each plugin mode independently; a single bad Redis value no longer drops overrides for the rest of the batch. - get_plugin_mode_override raises RuntimeError on Redis transport errors so callers can distinguish "no override" from "Redis unreachable". - Pub/sub listener reconnects with exponential backoff plus jitter and escalates to ERROR after five consecutive failures. Type safety: - Discriminated-union Pydantic models (_GlobalToggleMsg, _ModeChangeMsg, _BindingChangeMsg, _TeamBindingChangeMsg) replace the untyped dict on the pub/sub wire; a typo in the type field now fails validation instead of silently no-oping. - PluginToggleRequest / PluginToggleResponse / PluginModeUpdateRequest / PluginModeUpdateResponse replace hand-rolled isinstance / literal- tuple validation in admin.py. - _CachedManager frozen dataclass replaces the (manager, created_at) tuple used for cached tenant managers; every access site goes through is_expired and the defensive isinstance(entry, tuple) shim is gone. Hot-path performance: - are_plugins_enabled_shared memoizes the shared toggle in-process for 2 s, invalidated by the pub/sub listener on global_toggle and by enable_plugins. This drops the per-request Redis GET that previously landed on every authenticated request. Encapsulation: - Add public TenantPluginManagerFactory.invalidate_team(team_id) and iter_context_ids; tool_plugin_bindings no longer imports _plugin_manager_factory._lock / _managers except through publish_team_binding_change. Framework isolation: - New mcpgateway/plugins/framework/_redis.py shim replaces every direct mcpgateway.utils.redis_client import inside the framework package. The gateway registers a Redis provider at lifespan startup; framework code calls get_shared_redis_client which delegates to the provider. scripts/pre-commit/check_framework_imports.py now reports zero violations (base PR had 5). Tests: - New tests/unit/mcpgateway/test_admin_plugin_runtime.py pins RBAC deny paths (401, 403 with and without allow_admin_bypass, 404, and payload validation) for PUT /admin/plugins and PUT /admin/plugins/{name}. - New unit coverage for _apply_redis_mode_overrides (empty plugins, no override, applied override, invalid-mode-per-row, MGET failure). - New round-trip tests: publish_plugin_mode_change, mode_change handler; publish_team_binding_change, team_binding_change handler pins the wildcard-binding fix; the short-lived shared-toggle cache gets invalidated on global_toggle broadcasts. - conftest.py installs a dynamic Redis provider for unit tests so monkeypatching mcpgateway.utils.redis_client.get_redis_client still reaches the framework code under test. Docs: - docs/docs/api/plugin-bindings-api.md describes the pub/sub invalidation channel + TTL fallback rather than implying instant cluster-wide consistency. Refs #4292 Signed-off-by: Jonathan Springer <jps@s390x.com>
jonpspri
added a commit
that referenced
this pull request
Apr 19, 2026
…ilable
Follow-up on the review-fix commit: the prior behaviour reported
``redis_persisted=false`` and 200 OK when the Redis SET for a per-plugin
mode override failed, implying a valid "local fallback" had taken
effect. That was misleading — per-plugin mode overrides live only in
Redis (there is no in-memory fallback store), so when the SET fails the
cached managers rebuild from the DB/YAML config without the new mode,
and nothing actually changes on any worker.
PUT /admin/plugins/{name} now returns 503 when Redis is unreachable or
the SET fails, emits a structured warning (category=security,
resource_action=update_rejected), and skips invalidate_all_plugin_managers
entirely. PluginModeUpdateResponse.redis_persisted is always True on a
200 response.
Unit tests in test_admin_plugin_runtime.py updated: the two previous
``redis_persisted is False`` cases now pin the 503 contract and assert
that the publish broadcast never fires when the SET itself fails (no
divergent cluster state).
The global toggle (PUT /admin/plugins) is unaffected — its local
fallback is legitimate because ``_PLUGINS_ENABLED`` is in-memory state
that the calling worker does honor, and ``are_plugins_enabled_shared()``
reflects the new value on this worker even when Redis is down.
Refs #4292
Signed-off-by: Jonathan Springer <jps@s390x.com>
jonpspri
added a commit
that referenced
this pull request
Apr 19, 2026
Add an in-process override map so ``PUT /admin/plugins/{name}`` works on
single-node / Redis-less deployments. The previous commit returned 503
when Redis was unavailable — correct for a multi-node cluster where
mode changes must propagate, but a functional regression for operators
who run the gateway without Redis at all.
Framework changes:
- New ``_local_mode_overrides: dict[str, str]`` module-level map holds
per-plugin mode overrides in process.
- ``publish_plugin_mode_change`` always writes to the map first, then
attempts Redis SET + publish. The in-memory write succeeds
unconditionally; the returned bool now reflects only the Redis
outcome so callers can surface ``redis_persisted`` to users honestly.
- ``_apply_redis_mode_overrides`` (manager.py) merges both sources on
rebuild. Redis wins where both hold a value (cluster coordination
beats stale local drift); the local map fills the gap where Redis has
no entry. Result: the override always takes effect on this worker,
whether Redis is up or not.
- The mode_change pub/sub handler mirrors incoming broadcasts into the
local map so peer workers converge even when the MGET on rebuild
races with the Redis TTL expiry.
- ``start_plugin_invalidation_listener`` probes for a Redis client
first and skips the task entirely when no provider is registered —
single-node deployments no longer spin a 10 s retry loop forever.
- New ``get_local_mode_overrides()`` helper exported for callers and
tests that need to inspect the in-process map.
Admin endpoint:
- ``update_plugin_mode`` reverts to 200 OK with an honest
``redis_persisted`` flag. The warning-level log now reads "this
worker only — Redis unavailable" when the Redis write failed, making
the degraded mode visible without rejecting the request.
Tests:
- Two previous 503 tests become single-node-fallback regression pins:
they now assert that the override lands in
``get_local_mode_overrides()`` and that the response carries
``redis_persisted=False`` plus the new mode.
- New unit coverage in ``_apply_redis_mode_overrides``:
* local override applied when Redis has no key for that plugin
* Redis value wins when both Redis and local hold a value
* local map is the sole source when no Redis client is available
- conftest and the admin-test fixture clear
``_local_mode_overrides`` between tests.
Refs #4292
Signed-off-by: Jonathan Springer <jps@s390x.com>
jonpspri
added a commit
that referenced
this pull request
Apr 19, 2026
The in-process ``_local_mode_overrides`` map added in the previous commit never expired, so a Redis-synced entry outlived the 24 h TTL the operator set on the corresponding Redis key. Multi-node deployments drifted: one worker kept applying an override the cluster had already let expire. Each entry is now ``(mode, expires_at | None)``: - ``expires_at`` is a monotonic deadline 24 h out (aligned with the Redis ``ex=86400`` value) when the Redis SET actually succeeded or when the local entry came from a ``mode_change`` pub/sub frame (broadcasts only fire after a successful SET, so the expiry is well-defined). - ``expires_at`` is ``None`` when the Redis SET could not run — the operator never got confirmation a timer had started, so the local- only entry is durable until an explicit change or restart. This keeps single-node-no-Redis deployments working. ``_prune_expired_local_overrides`` runs before every snapshot read via ``get_local_mode_overrides``, so ``_apply_redis_mode_overrides`` never sees a stale entry on the rebuild path. New regression pins: - ``test_expired_redis_synced_local_override_is_pruned`` covers the original bug: an entry with a past ``expires_at`` must not apply, while a sibling durable entry must. - ``test_local_entry_expiry_aligns_with_redis_ttl_on_success`` asserts ``publish_plugin_mode_change`` stamps the expiry within tolerance of ``monotonic() + 86400`` on Redis success. - ``test_local_entry_has_no_expiry_when_redis_set_fails`` asserts the same helper writes ``expires_at=None`` when the SET fails. Existing ``_apply_redis_mode_overrides`` tests updated to the new tuple shape. Refs #4292 Signed-off-by: Jonathan Springer <jps@s390x.com>
jonpspri
added a commit
that referenced
this pull request
Apr 19, 2026
Addresses the correctness and encapsulation findings raised in the
second PR review. Breaks down into three buckets.
Correctness bugs (must):
- Factory initialization is no longer gated on ``settings.plugins.enabled``.
A node that booted with the flag off would never create the factory, so
a later shared-toggle flip to "enabled" from a peer worker left this
node unable to run plugins until restart. The factory now initializes
whenever the YAML config is valid; ``get_plugin_manager`` still gates
*execution* on the shared toggle. The invalidation listener is started
unconditionally for the same reason (it already early-exits when no
Redis provider is registered).
- ``GatewayTenantPluginManagerFactory.get_config_from_db`` no longer
swallows DB errors and returns ``None``. Returning the YAML base config
silently dropped every per-team/per-tool binding, including enforce-
mode security plugins. The method now re-raises with
``logger.exception`` so the rebuild fails loudly and the caller retries
on the next request or TTL expiry.
- ``shutdown_plugin_manager_factory`` dropped the ``if not _PLUGINS_ENABLED``
early-return. Runtime-disabling plugins (``PUT /admin/plugins
{"enabled": false}``) flips the in-memory flag off, but gateway
shutdown must still tear down the factory to avoid leaking in-flight
build tasks. Corresponding test updated to pin the new contract.
- ``tests/unit/mcpgateway/plugins/conftest.py`` stopped setting
``fw._plugin_manager = None`` (a nonexistent attribute) and now calls
``fw.reset_plugin_manager_factory()`` — the real singleton resetter.
Encapsulation + correctness (should):
- ``start_plugin_invalidation_listener`` guards the check-then-create-
task sequence with a module-level ``asyncio.Lock``. Concurrent callers
no longer both reach ``asyncio.create_task`` and leak a second Redis
subscription. Probe failures log at WARNING with the exception rather
than silently dropping to ``None``.
- ``_ModeChangeMsg`` frames now carry ``ttl_seconds`` and
``publish_plugin_mode_change`` populates it from the Redis key's TTL.
Peer workers use that value to stamp their local override copy, so
every worker shares one absolute deadline instead of anchoring on its
own reception time and drifting past Redis expiry. Older publishers
fall back to the 24 h default via the Pydantic field default.
- ``enable_plugins_shared`` and ``publish_plugin_mode_change`` now log
at ERROR when the Redis SET succeeded but the broadcast publish
failed. Partial writes silently hid cluster divergence — WARNING is
too quiet for a state where peers will lag the local worker.
- ``_apply_redis_mode_overrides`` builds an ordered Redis→local
candidate list and tries each in turn. A corrupt Redis value no
longer shadows a valid local override and falls through to YAML; it
logs and moves on to the local candidate.
- New public accessor ``get_plugin_manager_factory()`` exported from
the framework package. ``tool_plugin_bindings.py`` drops the three
private ``from mcpgateway.plugins.framework import
_plugin_manager_factory`` imports (and the three ``pylint: disable``
lines that went with them) in the upsert, delete-by-reference, and
single-UUID delete paths.
Test additions:
- ``TestListenerLocalMirror`` pins that ``mode_change`` broadcasts
populate ``_local_mode_overrides`` with the broadcast's
``ttl_seconds``, and that older publishers without the field fall
back to the 24 h default.
- ``TestListenerStartup`` covers three branches of
``start_plugin_invalidation_listener``:
* skip-start when no Redis provider is registered (single-node)
* concurrent-start TOCTOU — three racing callers produce one task
* probe failure logs at WARNING and does not start
- ``TestPublishPartialWriteSignaling`` pins the ERROR-on-broadcast-
failure upgrade for both ``enable_plugins_shared`` and
``publish_plugin_mode_change``.
- New ``test_corrupt_redis_falls_through_to_local_override`` pins that
a garbage Redis value no longer shadows a valid local override.
- Updated ``test_raises_on_db_error`` (was ``test_returns_none_on_db_error``)
to expect the new exception contract.
Refs #4292
Signed-off-by: Jonathan Springer <jps@s390x.com>
jonpspri
added a commit
that referenced
this pull request
Apr 19, 2026
…abled The previous commit wrapped ``init_plugin_manager_factory`` in a broad ``except Exception`` and logged at WARNING on failure. That swallowed real problems — a missing YAML, a malformed config, or a plugin module that fails to import would now boot the gateway with ``_plugin_manager_factory = None`` and a single WARNING line. Operators who set ``plugins.enabled=true`` expect plugins to run; a silent no-op booted gateway is a regression from the pre-PR behaviour. The fix splits the init failure path by intent: - When ``settings.plugins.enabled`` is ``True``, re-raise. The outer lifespan handler logs ``Error during startup`` and re-raises, matching the original hard-crash semantics. ``logger.error(..., exc_info=True)`` ensures the traceback reaches the log stream before the re-raise. - When ``settings.plugins.enabled`` is ``False``, keep the graceful degradation introduced in the previous commit. Opportunistic init exists so a later shared-toggle flip from a peer worker can enable plugins without a restart; if that opportunistic init fails, the gateway still boots (the operator didn't ask for plugins anyway), but a peer-driven runtime-enable will require fixing the config and restarting this node. Refs #4292 Signed-off-by: Jonathan Springer <jps@s390x.com>
jonpspri
added a commit
that referenced
this pull request
Apr 19, 2026
Seven simplifications distilled from the third-pass review that didn't make the "must + should" bundle. No behavior changes. Production code: - ``_handle_invalidation_message`` converts the four ``isinstance(frame, X)`` branches into a ``match/case`` on the discriminated union. Drops four ``return`` statements; union closure is explicit in the dispatch. - New ``_invalidate_and_broadcast(bindings)`` helper in ``tool_plugin_bindings.py`` replaces the copy-pasted wildcard-vs-specific split across three handlers (upsert, delete-by-reference, single-UUID delete). DELETE single passes a one-element list. Saves ~25 lines and makes the three routes identical in shape so future changes land in one place. - ``admin.update_plugin_mode`` drops the vestigial ``request.app.state.plugin_manager`` lookup + ``set_plugin_manager`` call. ``plugin_service`` is a module singleton that lifespan already wires; the endpoint-level re-injection was dead code. ``request`` is no longer needed so it's removed from the signature; ``db`` is renamed to ``_db`` since it's an rbac-decorator-required dependency rather than an actual use. Admin-test call sites updated to drop the ``request=mock_request`` keyword. Test consolidation: - Module-level ``_make_bare_factory(**attrs)`` in ``test_plugin_runtime_management.py`` replaces ten raw ``TenantPluginManagerFactory.__new__`` + attribute-setup blocks. ``TestApplyRedisModeOverrides._make_factory`` now delegates to the shared helper so every test bypasses ``__init__`` the same way. - New ``tests/utils/plugin_redis_helper.py`` — ``@contextmanager install_dynamic_redis_provider()`` captures the per-test provider-registration dance. Both the plugin conftest and ``test_admin_plugin_runtime.py`` now use it, eliminating two copies of the same 14-line fixture. Placed under ``tests/utils/`` (matches ``rbac_mocks.py``) since the pytest-naming hook excludes that tree. Integration-test scope reduction: - ``ensure_plugins_enabled`` in the multi-instance integration file now probes the shared toggle first and only PUTs + sleeps the NGINX-cache-TTL interval when the state actually needs changing. Tests that don't toggle plugins no longer pay the ~6 s wait. Suite wall-clock drops from minutes to seconds. TODO markers: - Added ``TODO(#4300):`` notes to ``TestCrossReplicaPropagation`` and ``TestMultiInstanceGlobalToggle`` class docstrings, linking the replica-identity-verification gap to the deferred follow-up issue so the work stays discoverable. Refs #4292 Signed-off-by: Jonathan Springer <jps@s390x.com>
jonpspri
added a commit
that referenced
this pull request
Apr 19, 2026
… correctness The previous commit (``f7f3259d2``) probed the global toggle state before PUT+sleep to skip the NGINX cache wait when plugins were already enabled. That was wrong: the probe goes through the same NGINX instance that caches ``GET /admin/plugins`` responses for ``NGINX_CACHE_TTL`` seconds. A test that disabled plugins and didn't re-enable in its own body would leave the NGINX cache still returning ``plugins_globally_enabled=True`` (from before the disable) when the next test's fixture probed, so the fixture would skip the re-enable and the next test would silently run against disabled plugins. Revert to the unconditional PUT+sleep pattern. The ~6 s per test is the cost of a reliable fixture when the thing you're waiting for is the very NGINX cache window you're trying to avoid. TODO(#4300) markers from ``f7f3259d2`` are preserved. Refs #4292 Signed-off-by: Jonathan Springer <jps@s390x.com>
jonpspri
added a commit
that referenced
this pull request
Apr 19, 2026
…ak import cycle Pylint R0401 flagged three cyclic imports rooted in ``framework -> framework.manager -> framework`` because ``manager.py`` lazy-imported ``get_local_mode_overrides`` from ``framework/__init__.py`` to resolve Redis MGET fallbacks. That closed a static cycle which pylint's whole-package analysis picked up. New ``mcpgateway/plugins/framework/_state.py`` leaf submodule now owns: - ``_local_mode_overrides`` dict (exposed via ``get_local_mode_overrides_raw``, ``set_local_mode_override``, ``clear_local_mode_overrides``). - ``_FACTORY_INIT_DEGRADED`` / ``_FACTORY_INIT_DEGRADED_LOGGED`` flags (exposed via ``mark_factory_init_degraded``, ``is_factory_init_degraded``, ``mark_factory_init_degraded_logged``, ``is_factory_init_degraded_logged``, ``reset_factory_init_degraded_for_tests``). ``framework/__init__.py``: - Imports ``_state`` once at the top-of-module (``noqa: E402`` gone). - Aliases the override dict at ``_local_mode_overrides`` so existing mutation sites keep working unchanged. - Degraded-boot helpers now delegate to the ``_state`` API. - Renamed the two degraded-flag module constants to UPPER_CASE (``_FACTORY_INIT_DEGRADED`` / ``_FACTORY_INIT_DEGRADED_LOGGED``), matching the ``_PLUGINS_ENABLED`` precedent — fixes pylint C0103. - Moved ``from mcpgateway.plugins.framework._redis import get_shared_redis_client as _redis`` up into the regular top-of-module import block — fixes pylint C0413. ``framework/manager.py``: - ``_apply_redis_mode_overrides`` now imports ``get_local_mode_overrides_raw`` from ``framework._state`` directly, reading the ``(mode, expires_at)`` tuple map and filtering expired entries inline. No more path through ``framework/__init__.py``. Pylint on the full package now reports 0 R0401 findings and a 10.00/10 rating for the touched files. 1230 tests pass; framework-isolation hook clean. Refs #4292 Signed-off-by: Jonathan Springer <jps@s390x.com>
Consolidates the review-driven changes on top of the initial runtime plugin management commit: - Redis is authoritative for both the global toggle and per-plugin mode overrides; single-node deployments fall through to an in-process map with explicit ``redis_persisted`` signalling in the admin responses. - Redis-synced local overrides expire at the cluster's 24h TTL so workers don't keep applying overrides the cluster has already released; durable entries (Redis unavailable at write time) remain sticky. - Factory-init failures on nodes with plugins disabled are recorded and surface one ERROR the first time a shared-toggle request hits a degraded node, instead of being silently swallowed. - Runtime-state globals live in a leaf ``_state.py`` to break the ``framework → manager → framework`` import cycle; writers go through ``_state.set_local_mode_override`` and ``prune_expired_local_overrides`` so snapshot/prune semantics stay consistent. - Admin toggle handler refreshes ``app.state.plugin_manager`` and the ``PluginService`` singleton so freshly disabled nodes can serve the runtime-enabled subsystem without a restart; the inverse disable path clears them. - Admin plugin-view GETs run a best-effort self-heal that always mirrors ``framework.get_plugin_manager()`` (TTL-cached) into the admin caches, so remote disables take effect on this worker's next read and a swallowed toggle-sync failure cannot leave views stale. - ``update_plugin_mode`` validates against the configured plugin set instead of the live manager, so operators can pre-stage per-plugin overrides on a process that booted with plugins disabled. - Test suite updated and expanded: deny-path regressions for the admin cache sync, remote-disable self-heal, configured-name validation, expired-override pruning, and backing-dict identity. Signed-off-by: Jonathan Springer <jps@s390x.com>
jonpspri
force-pushed
the
feat/test-plugin-runtime-management
branch
from
April 19, 2026 16:33
740886a to
5561155
Compare
Lifespan-exercising tests in ``test_main_extended.py`` monkeypatch ``main.get_redis_client`` to an ``AsyncMock`` before triggering lifespan, which registers that mock as the plugin framework's shared Redis provider. ``set_shared_redis_provider`` is module-level state that ``monkeypatch`` doesn't roll back, so the mock bled into subsequent tests: the next call to ``_read_shared_enabled`` treated the mock's return value as a real Redis reply, decoded to ``False``, and made ``get_plugin_manager`` return ``None`` even after the test had set ``_PLUGINS_ENABLED = True``. Adds an autouse fixture in ``tests/unit/mcpgateway/conftest.py`` that clears the shared provider before and after each test. Plugin-suite tests re-install their dynamic provider after this runs, so behaviour there is unchanged. Signed-off-by: Jonathan Springer <jps@s390x.com>
Two error-swallow regression tests asserted on ``caplog.records`` to prove the warning path was hit. That capture is brittle under pytest-xdist: if any earlier test in the same worker triggered the app's lifespan, its ``LoggingService.initialize`` calls ``root_logger.handlers.clear()`` and wipes caplog's handler, so subsequent ``LOGGER.warning`` calls never reach the capture fixture. The tests now verify the observable behaviour — the operation returns normally instead of raising, and the failing sync step was actually exercised (via ``assert_called_once_with``/sentinel) rather than skipped. Equally strong guarantee, no log-capture dependency. Signed-off-by: Jonathan Springer <jps@s390x.com>
The prior rewrite dropped caplog entirely, which lost the regression pin on the WARNING log a future refactor could accidentally remove. Replaces caplog with a small ``_capture_admin_logger_records`` context manager that attaches a handler directly to the ``mcpgateway.admin`` logger. Logger-local capture is immune to the xdist hazard that caused the CI failure (``LoggingService.initialize`` calls ``root_logger.handlers.clear()`` during lifespan, wiping caplog's root-attached handler) and also bypasses the root level gate — so the warning assertion remains reliable regardless of which tests ran earlier in the same worker. Signed-off-by: Jonathan Springer <jps@s390x.com>
…gs router, and lifespan Adds targeted regression pins for the remaining uncovered lines: - ``framework/__init__.py`` (86% → 100%): Redis-transport failure branches in ``_read_shared_enabled``, ``enable_plugins_shared``, ``_publish_invalidation``, ``publish_plugin_mode_change`` and ``get_plugin_mode_override``; the ``list_configured_plugin_names``/``get_plugin_manager_factory`` accessors; unknown-frame rejection and swallow-and-log paths in ``_handle_invalidation_message``; and the listener's polling-when-no-client and subscribe/dispatch/cancel branches. - ``framework/manager.py`` (95% → 98%): TTL-expired cache eviction, ``_apply_redis_mode_overrides`` client-factory failure + model_copy ValidationError, and the swallow-and-log semantics of ``invalidate_all`` / ``invalidate_team`` plus the ``iter_context_ids`` snapshot. - ``admin.py``: ``update_plugin_mode`` no longer 500s when ``invalidate_all_plugin_managers`` raises — WARNs instead. - ``tool_plugin_bindings.py`` (66.7% → 100%): wildcard ``tool_name="*"`` binding routes through ``factory.invalidate_team`` + team-scoped publish, and still broadcasts when the local factory is degraded. - ``main.py`` lifespan: plugin-factory init failure crashes loud when ``plugins.enabled=true`` and marks the node degraded when it's false; a ``stop_plugin_invalidation_listener`` shutdown failure is swallowed. Signed-off-by: Jonathan Springer <jps@s390x.com>
Earlier iterations tried ``caplog`` (lost when lifespan clears root handlers) and a logger-local handler (still vulnerable to ``logger.disabled`` flips, filter additions, or LOG_LEVEL/effective-level gates depending on what other tests in the same xdist worker did). Both kept failing intermittently in CI. Replaces ``_capture_admin_logger_records`` with a direct ``patch.object`` on ``admin_module.LOGGER``. The spy records what the production code actually called; the standard logging chain is no longer in the test path at all, so worker ordering can't perturb the assertion. Signed-off-by: Jonathan Springer <jps@s390x.com>
7 tasks
This was referenced Apr 20, 2026
gandhipratik203
added a commit
that referenced
this pull request
Apr 22, 2026
…/Pydantic
Additional hardening noticed while running the plugin-manager integration
suite against the rebuilt gateway image. Three validation-endpoint tests
were asserting ``status_code == 400`` but the server was returning 422 or
200 — the assertions were written against the RFC 7231 generic-400
convention, whereas FastAPI follows the RFC 4918 convention of returning
422 for request-body validation failures, and Pydantic's default ``bool``
type leniently coerces truthy strings like ``"yes"`` to ``True``.
Three fixes:
- ``test_missing_enabled_field`` — accept ``(400, 422)`` with a comment
explaining the FastAPI convention and why both are tolerated.
- ``test_non_boolean_enabled`` — renamed to
``test_truthy_string_enabled_coerced_to_bool`` and reworked to assert
the actual contract: ``{"enabled": "yes"}`` returns 200 and the
subsequent GET confirms the flag flipped to ``True``. Pinning the
coercion behaviour so it's not accidentally changed without a
deliberate ``StrictBool`` decision.
- ``test_invalid_mode_returns_400`` — renamed to
``test_invalid_mode_returns_4xx`` and widened to accept ``(400, 422)``
with the same FastAPI-convention comment.
No server-side changes. These tests were originally written in PR #4292;
this commit just brings their expectations in line with the framework's
actual documented behaviour.
Verified: 23/23 tests pass in ``test_plugin_runtime_management.py``
against the live 3-replica docker-compose stack.
Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
gandhipratik203
added a commit
that referenced
this pull request
Apr 22, 2026
…/Pydantic
Additional hardening noticed while running the plugin-manager integration
suite against the rebuilt gateway image. Three validation-endpoint tests
were asserting ``status_code == 400`` but the server was returning 422 or
200 — the assertions were written against the RFC 7231 generic-400
convention, whereas FastAPI follows the RFC 4918 convention of returning
422 for request-body validation failures, and Pydantic's default ``bool``
type leniently coerces truthy strings like ``"yes"`` to ``True``.
Three fixes:
- ``test_missing_enabled_field`` — accept ``(400, 422)`` with a comment
explaining the FastAPI convention and why both are tolerated.
- ``test_non_boolean_enabled`` — renamed to
``test_truthy_string_enabled_coerced_to_bool`` and reworked to assert
the actual contract: ``{"enabled": "yes"}`` returns 200 and the
subsequent GET confirms the flag flipped to ``True``. Pinning the
coercion behaviour so it's not accidentally changed without a
deliberate ``StrictBool`` decision.
- ``test_invalid_mode_returns_400`` — renamed to
``test_invalid_mode_returns_4xx`` and widened to accept ``(400, 422)``
with the same FastAPI-convention comment.
No server-side changes. These tests were originally written in PR #4292;
this commit just brings their expectations in line with the framework's
actual documented behaviour.
Verified: 23/23 tests pass in ``test_plugin_runtime_management.py``
against the live 3-replica docker-compose stack.
Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
gandhipratik203
added a commit
that referenced
this pull request
Apr 22, 2026
…/Pydantic
Additional hardening noticed while running the plugin-manager integration
suite against the rebuilt gateway image. Three validation-endpoint tests
were asserting ``status_code == 400`` but the server was returning 422 or
200 — the assertions were written against the RFC 7231 generic-400
convention, whereas FastAPI follows the RFC 4918 convention of returning
422 for request-body validation failures, and Pydantic's default ``bool``
type leniently coerces truthy strings like ``"yes"`` to ``True``.
Three fixes:
- ``test_missing_enabled_field`` — accept ``(400, 422)`` with a comment
explaining the FastAPI convention and why both are tolerated.
- ``test_non_boolean_enabled`` — renamed to
``test_truthy_string_enabled_coerced_to_bool`` and reworked to assert
the actual contract: ``{"enabled": "yes"}`` returns 200 and the
subsequent GET confirms the flag flipped to ``True``. Pinning the
coercion behaviour so it's not accidentally changed without a
deliberate ``StrictBool`` decision.
- ``test_invalid_mode_returns_400`` — renamed to
``test_invalid_mode_returns_4xx`` and widened to accept ``(400, 422)``
with the same FastAPI-convention comment.
No server-side changes. These tests were originally written in PR #4292;
this commit just brings their expectations in line with the framework's
actual documented behaviour.
Verified: 23/23 tests pass in ``test_plugin_runtime_management.py``
against the live 3-replica docker-compose stack.
Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
brian-hussey
pushed a commit
that referenced
this pull request
Apr 22, 2026
…rate limiting (#4343) (#4380) * test(plugins): integration tests for dynamic plugin behavior Adds two integration test files that verify runtime plugin mode changes via the admin API affect actual plugin behavior on tool calls. test_plugin_dynamic_behavior.py (7 tests, all pass): Uses ReplaceBadWordsPlugin with fast-test-echo tool to verify text transformation starts/stops when the plugin is enabled/disabled at runtime. Covers enforce, disable, re-enable, cross-replica consistency, and full toggle cycle. test_rate_limiter_dynamic_behavior.py (6 tests, 5 pass, 1 known failure): Tests RateLimiterPlugin dynamic enable/disable with tool call bursts. Verifies rate limiting activates on first enable and Redis state propagation works. The disable→re-enable toggle cycle test fails — the rate limiter does not re-activate after being disabled and re-enabled within the same flow (G3 in #4343). Refs #4343 Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * fix(tool-service): populate tenant_id from tool_payload on fallback paths G1 from issue #4343. The happy path (HTTP request → HttpAuthMiddleware builds GlobalContext → get_current_user calls _propagate_tenant_id to fill tenant_id from request.state.team_id → tool_service reuses the context) already worked. The fallback branches in _build_rust_tool_hook_global_context and invoke_tool — which fire when middleware never ran — were still constructing GlobalContext with tenant_id hardcoded to None. Rate limiter's by_tenant dimension was therefore silently a no-op on those paths. Both fallbacks now derive tenant_id from tool_payload["team_id"] (already in scope — invoke_tool uses it at line 4463 for plugin context keying). Non-string values are ignored defensively. When plugin_global_context is supplied but carries tenant_id=None, the payload-derived value fills it in without overwriting an already-set value. Unit tests in tests/unit/mcpgateway/services/test_tool_service_tenant_id.py pin: happy propagation, absent team_id stays None, non-string team_id is ignored. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): multi-tenant integration tests for rate limiter (G1 + G2) Pins the G1 / G2 gaps from issue #4343 end-to-end through the gateway HTTP flow, against a running docker-compose stack. Two tests under ``TestTenantIdFlowsToPlugin``: - ``test_tool_invocation_creates_rate_limit_keys_in_redis`` — sanity check. After enabling the rate limiter and making a real tool invocation, at least one ``rl:*`` key must exist in Redis. If this fails, the rate limiter isn't engaging on the tool path at all and everything else is meaningless. - ``test_rate_limit_keys_carry_tenant_prefix_when_tool_is_team_owned`` — G2 end-to-end. When the invoked tool belongs to a team, ``GlobalContext.tenant_id`` must flow through the tool service to the plugin and land as a prefix in the Redis key: ``rl:{team_id}:user:{email}:{window}``. Skips cleanly if the auto-detected server has no team owner. Current behaviour on a running stack built from the baseline image: the second test fails with keys in the unprefixed format (``rl:user:{email}:60``), which is the exact observable symptom of G1 — ``request.state.team_id`` isn't reaching the plugin's ``GlobalContext``. Will go green once the gateway redeploys with the tool-service fix from the previous commit in this PR, provided the deployment has admin team membership wired up via RBAC. Helpers: - Auth via the shared gateway session-token flow (same pattern as ``test_rate_limiter_dynamic_behavior.py``). - Redis inspection via ``docker exec`` against the ``mcp-context-forge-redis-1`` container so the test sidesteps any auth-config mismatch between the gateway's Redis client and a test-side client. - Autouse fixture disables the plugin and flushes ``rl:*`` keys between tests so each test starts from a clean slate. Integration-gated behind ``--with-integration`` and skipped when the gateway isn't reachable at ``http://localhost:8080``. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): burst-mode toggle cycle against ReplaceBadWordsPlugin Diagnostic counterpart to ``test_rate_limiter_dynamic_behavior.py::test_disable_enable_disable_cycle``, which surfaces a 7/40 residual symptom at Step 1 (disabled after enforce): some requests still get HTTP 429 even after 7s of PROPAGATION_WAIT post mode=disabled. This new test mirrors the rate-limiter burst test exactly — same 3-step cycle, same BURST_SIZE=40, same PROPAGATION_WAIT via ``_set_plugin_mode`` — but against ``ReplaceBadWordsPlugin`` which has no Redis state, no Rust engine, no plugin-specific caching. If the rate-limiter symptom were framework-wide (mode invalidation not propagating fast enough across the 3 gateway replicas), bad-words would show the same partial-propagation pattern. If it's rate-limiter-specific (residual state in the plugin's Redis counters or Rust core), bad-words stays clean. Locally: bad-words sees 40/40 unchanged at Step 1, 40/40 transformed at Step 2, 40/40 unchanged at Step 3. All three steps clean. This isolates the rate-limiter's 7/40 residual as plugin-specific, not a framework mode-propagation issue. Also serves as a permanent regression pin on the framework's mode propagation — if the cross-replica invalidation ever breaks, this test is designed to surface it via the same 7/40 partial-convergence pattern. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): drop the enforce→disabled toggle burst test ``TestRateLimiterToggleCycle::test_disable_enable_disable_cycle`` surfaced a 7/40 residual at Step 1 (disabled after enforce) that isn't reachable within this PR's scope. The parallel burst-cycle test we added for ReplaceBadWordsPlugin (TestBadWordsToggleBurst) hits the same gateway stack with the same shape and comes back 40/40 clean at every step, so framework mode-propagation is demonstrably fine. The residual is rate-limiter-specific and lives somewhere in the plugin shim or Rust core — worth its own investigation PR rather than keeping a failing test around as background noise. Removing entirely instead of marking xfail: the original plugin-manager work landed without this particular burst-cycle assertion, nobody relied on it as a contract, and keeping it xfailed adds a red line to every local integration run for no one's benefit. If someone revisits the rate-limiter lifecycle behaviour, they can recreate the test shape then — we already have a reusable counterpart pattern in TestBadWordsToggleBurst to copy from. The other five tests in this file (burst allowed when disabled, burst enforce, mode persisted in Redis, mode visible via admin API, mode reverts after disable) continue to pass and cover the ongoing behaviour that matters. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * fix: address rate limiter review findings Signed-off-by: Jonathan Springer <jps@s390x.com> * test(tool-service): add coverage for tenant_id preservation branch Adds test case for line 4568 in tool_service.py where existing GlobalContext.tenant_id is preserved when already set by middleware, rather than being overwritten by tool_payload team_id. Achieves 100% coverage of tenant_id propagation logic in _build_rust_tool_hook_global_context method. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(loadtest): adapt rate-limiter scale-test Redis scans to the tenant-prefixed key layout The G1/G2 fix in this branch moves rate-limiter keys for team-owned tools from ``rl:{dim}:{id}:{win}`` to ``rl:{tenant_id}:{dim}:{id}:{win}``. The scale test's Redis scans at ``_poll_redis_once`` and ``_detect_algorithm_from_redis`` used ``rl:user:*`` / ``rl:tenant:*`` globs that only match the pre-fix layout — post-fix they return zero and the test reports silently-wrong metrics (Redis key delta of 0 even though rate limiting is actively producing keys). Added two helpers: - ``_scan_rl_dimension(dim)`` — count keys across both layouts - ``_scan_rl_sample_keys(dim)`` — sample keys for algorithm detection Each unions ``rl:{dim}:*`` (unprefixed, single-tenant fallback) and ``rl:*:{dim}:*`` (tenant-prefixed, multi-tenant), so the test works against pre-fix deployments, post-fix deployments, and mixed workloads where the tool path produces prefixed keys and the prompt path produces unprefixed keys. Validated against the live 3-replica gateway: - Before: old helpers return 0 keys despite 11 actual keys in Redis - After: new helpers return 10 user keys + 1 tenant key = 11 ✓ - Algorithm detection: sample key found, Redis TYPE = string, banner shows "fixed_window ✅ matches config" - Other three rate-limiter locust files (locustfile_rate_limiter, locustfile_rate_limiter_backend_correctness, locustfile_rate_limiter_redis_capacity) don't scan Redis keys and are unaffected — all three run cleanly end-to-end. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): align runtime-management validation tests with FastAPI/Pydantic Additional hardening noticed while running the plugin-manager integration suite against the rebuilt gateway image. Three validation-endpoint tests were asserting ``status_code == 400`` but the server was returning 422 or 200 — the assertions were written against the RFC 7231 generic-400 convention, whereas FastAPI follows the RFC 4918 convention of returning 422 for request-body validation failures, and Pydantic's default ``bool`` type leniently coerces truthy strings like ``"yes"`` to ``True``. Three fixes: - ``test_missing_enabled_field`` — accept ``(400, 422)`` with a comment explaining the FastAPI convention and why both are tolerated. - ``test_non_boolean_enabled`` — renamed to ``test_truthy_string_enabled_coerced_to_bool`` and reworked to assert the actual contract: ``{"enabled": "yes"}`` returns 200 and the subsequent GET confirms the flag flipped to ``True``. Pinning the coercion behaviour so it's not accidentally changed without a deliberate ``StrictBool`` decision. - ``test_invalid_mode_returns_400`` — renamed to ``test_invalid_mode_returns_4xx`` and widened to accept ``(400, 422)`` with the same FastAPI-convention comment. No server-side changes. These tests were originally written in PR #4292; this commit just brings their expectations in line with the framework's actual documented behaviour. Verified: 23/23 tests pass in ``test_plugin_runtime_management.py`` against the live 3-replica docker-compose stack. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): verify rl:* flush completes before proceeding Addresses PR review feedback: add a post-DEL verify that confirms the rl:* keyspace is empty before a test proceeds, to make flush failures fail loudly rather than silently pollute the next test. Note: Redis DEL is synchronous and atomic, so this is a belt-and- suspenders assertion rather than a race-condition fix, but it gives debuggable failure if docker-exec or redis-cli ever returns a spurious success while the key survives. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): rename dynamic-behavior test to reflect its worked-example scope The file holds one concrete case (ReplaceBadWordsPlugin + fast-test-echo) proving that mode changes via the admin API actually affect tool-call behaviour across gateway replicas. The previous filename implied broader coverage of 'dynamic plugin configuration', so rename to test_plugin_dynamic_behavior_bad_words.py and extend the docstring with a copy-and-adapt note for future per-plugin variants, plus an explicit note about the ReplaceBadWordsPlugin config dependency. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): pin empty-string contract and parameterize Redis container name Addresses PR review follow-ups F1 and F3: F1: _extract_tenant_id_from_payload treated empty-string team_id as absent via the bare truthy check, with no signal the contract was intentional. Extend the docstring to pin the rule so a future reader doesn't decide the falsy-string branch is an oversight and let empty values through — a zero-length tenant prefix would collapse tenant-scoped Redis keys onto the unscoped layout and silently break isolation. F3: the integration test docker-exec calls hardcoded the Redis container name (mcp-context-forge-redis-1), which is the compose default but derives from the project-name prefix. A checkout under a different directory name or a custom COMPOSE_PROJECT_NAME silently skips every test here instead of failing loudly. Read the container name from a REDIS_CONTAINER_NAME env var with the current value as the default, so the common case stays zero-config while non-default deployments have a documented escape hatch. Matches the DOCKER_REDIS_CONTAINER pattern already used in the locustfiles. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * refactor(tool-service): hoist tool-payload GlobalContext enrichment into shared helper Both _build_rust_tool_hook_global_context and invoke_tool have the same fill-missing block for server_id, user, and tenant_id on an already- existing GlobalContext. The blocks drifted only in variable names, not semantics. Extract the shared logic into _apply_tool_payload_to_global_context so the two call sites stay in lockstep, and the helper is covered by a single unit test rather than needing per-site exercise of identical logic (which was the root of the diff-coverage gap at tool_service.py:4572). Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * chore(deps): bump cpex-rate-limiter to 0.0.4 Picks up the production hardening released in cpex-plugins 0.0.4 (PR IBM/cpex-plugins#40, released as rate-limiter-v0.0.4): tenant- scoped Redis keys, strict fail_mode validation, initialize/shutdown lifecycle hooks, and parse_rate bounds. Paired with the G1 tenant_id propagation fix already on this branch, this unblocks end-to-end by_user and by_tenant enforcement across multi-tenant deployments. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> --------- Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> Signed-off-by: Jonathan Springer <jps@s390x.com> Co-authored-by: Jonathan Springer <jps@s390x.com>
gcgoncalves
pushed a commit
that referenced
this pull request
Apr 23, 2026
…mode, cross-instance propagation (#4292) * feat(plugins): runtime plugin management — global toggle, per-plugin mode, cross-instance propagation Adds runtime plugin management capabilities — global enable/disable, per-plugin mode changes, and cross-worker/cross-pod state propagation via Redis. Closes 14 multi-instance gaps identified in the plugin configuration system. Key changes: - PUT /admin/plugins — global enable/disable via Redis - PUT /admin/plugins/{name} — per-plugin mode change (enforce/permissive/disabled) - GET /admin/plugins — includes plugins_globally_enabled from runtime state - TTL-based cache refresh (30s default) for eventual consistency across instances - Wildcard binding invalidation fix — evicts all team contexts on * binding - DB error fallback — graceful degradation when Postgres is temporarily unavailable - MGET batched Redis reads for mode overrides - Structured audit logging for all plugin state changes - 43 tests (23 unit + 20 integration) Co-authored-by: cafalchio <mcafalchio@gmail.com> Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * feat(plugins): runtime plugin management — address review findings Consolidates the review-driven changes on top of the initial runtime plugin management commit: - Redis is authoritative for both the global toggle and per-plugin mode overrides; single-node deployments fall through to an in-process map with explicit ``redis_persisted`` signalling in the admin responses. - Redis-synced local overrides expire at the cluster's 24h TTL so workers don't keep applying overrides the cluster has already released; durable entries (Redis unavailable at write time) remain sticky. - Factory-init failures on nodes with plugins disabled are recorded and surface one ERROR the first time a shared-toggle request hits a degraded node, instead of being silently swallowed. - Runtime-state globals live in a leaf ``_state.py`` to break the ``framework → manager → framework`` import cycle; writers go through ``_state.set_local_mode_override`` and ``prune_expired_local_overrides`` so snapshot/prune semantics stay consistent. - Admin toggle handler refreshes ``app.state.plugin_manager`` and the ``PluginService`` singleton so freshly disabled nodes can serve the runtime-enabled subsystem without a restart; the inverse disable path clears them. - Admin plugin-view GETs run a best-effort self-heal that always mirrors ``framework.get_plugin_manager()`` (TTL-cached) into the admin caches, so remote disables take effect on this worker's next read and a swallowed toggle-sync failure cannot leave views stale. - ``update_plugin_mode`` validates against the configured plugin set instead of the live manager, so operators can pre-stage per-plugin overrides on a process that booted with plugins disabled. - Test suite updated and expanded: deny-path regressions for the admin cache sync, remote-disable self-heal, configured-name validation, expired-override pruning, and backing-dict identity. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(plugins): reset framework Redis provider between tests Lifespan-exercising tests in ``test_main_extended.py`` monkeypatch ``main.get_redis_client`` to an ``AsyncMock`` before triggering lifespan, which registers that mock as the plugin framework's shared Redis provider. ``set_shared_redis_provider`` is module-level state that ``monkeypatch`` doesn't roll back, so the mock bled into subsequent tests: the next call to ``_read_shared_enabled`` treated the mock's return value as a real Redis reply, decoded to ``False``, and made ``get_plugin_manager`` return ``None`` even after the test had set ``_PLUGINS_ENABLED = True``. Adds an autouse fixture in ``tests/unit/mcpgateway/conftest.py`` that clears the shared provider before and after each test. Plugin-suite tests re-install their dynamic provider after this runs, so behaviour there is unchanged. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(admin): drop caplog dependency from error-swallow regression pins Two error-swallow regression tests asserted on ``caplog.records`` to prove the warning path was hit. That capture is brittle under pytest-xdist: if any earlier test in the same worker triggered the app's lifespan, its ``LoggingService.initialize`` calls ``root_logger.handlers.clear()`` and wipes caplog's handler, so subsequent ``LOGGER.warning`` calls never reach the capture fixture. The tests now verify the observable behaviour — the operation returns normally instead of raising, and the failing sync step was actually exercised (via ``assert_called_once_with``/sentinel) rather than skipped. Equally strong guarantee, no log-capture dependency. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(admin): restore warning-path pins via logger-local handler The prior rewrite dropped caplog entirely, which lost the regression pin on the WARNING log a future refactor could accidentally remove. Replaces caplog with a small ``_capture_admin_logger_records`` context manager that attaches a handler directly to the ``mcpgateway.admin`` logger. Logger-local capture is immune to the xdist hazard that caused the CI failure (``LoggingService.initialize`` calls ``root_logger.handlers.clear()`` during lifespan, wiping caplog's root-attached handler) and also bypasses the root level gate — so the warning assertion remains reliable regardless of which tests ran earlier in the same worker. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(plugins): close coverage gaps on framework, manager, tool-bindings router, and lifespan Adds targeted regression pins for the remaining uncovered lines: - ``framework/__init__.py`` (86% → 100%): Redis-transport failure branches in ``_read_shared_enabled``, ``enable_plugins_shared``, ``_publish_invalidation``, ``publish_plugin_mode_change`` and ``get_plugin_mode_override``; the ``list_configured_plugin_names``/``get_plugin_manager_factory`` accessors; unknown-frame rejection and swallow-and-log paths in ``_handle_invalidation_message``; and the listener's polling-when-no-client and subscribe/dispatch/cancel branches. - ``framework/manager.py`` (95% → 98%): TTL-expired cache eviction, ``_apply_redis_mode_overrides`` client-factory failure + model_copy ValidationError, and the swallow-and-log semantics of ``invalidate_all`` / ``invalidate_team`` plus the ``iter_context_ids`` snapshot. - ``admin.py``: ``update_plugin_mode`` no longer 500s when ``invalidate_all_plugin_managers`` raises — WARNs instead. - ``tool_plugin_bindings.py`` (66.7% → 100%): wildcard ``tool_name="*"`` binding routes through ``factory.invalidate_team`` + team-scoped publish, and still broadcasts when the local factory is degraded. - ``main.py`` lifespan: plugin-factory init failure crashes loud when ``plugins.enabled=true`` and marks the node degraded when it's false; a ``stop_plugin_invalidation_listener`` shutdown failure is swallowed. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(admin): intercept LOGGER directly in admin warning-path pins Earlier iterations tried ``caplog`` (lost when lifespan clears root handlers) and a logger-local handler (still vulnerable to ``logger.disabled`` flips, filter additions, or LOG_LEVEL/effective-level gates depending on what other tests in the same xdist worker did). Both kept failing intermittently in CI. Replaces ``_capture_admin_logger_records`` with a direct ``patch.object`` on ``admin_module.LOGGER``. The spy records what the production code actually called; the standard logging chain is no longer in the test path at all, so worker ordering can't perturb the assertion. Signed-off-by: Jonathan Springer <jps@s390x.com> --------- Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> Signed-off-by: Jonathan Springer <jps@s390x.com> Co-authored-by: cafalchio <mcafalchio@gmail.com> Co-authored-by: Jonathan Springer <jps@s390x.com>
gcgoncalves
pushed a commit
that referenced
this pull request
Apr 23, 2026
…rate limiting (#4343) (#4380) * test(plugins): integration tests for dynamic plugin behavior Adds two integration test files that verify runtime plugin mode changes via the admin API affect actual plugin behavior on tool calls. test_plugin_dynamic_behavior.py (7 tests, all pass): Uses ReplaceBadWordsPlugin with fast-test-echo tool to verify text transformation starts/stops when the plugin is enabled/disabled at runtime. Covers enforce, disable, re-enable, cross-replica consistency, and full toggle cycle. test_rate_limiter_dynamic_behavior.py (6 tests, 5 pass, 1 known failure): Tests RateLimiterPlugin dynamic enable/disable with tool call bursts. Verifies rate limiting activates on first enable and Redis state propagation works. The disable→re-enable toggle cycle test fails — the rate limiter does not re-activate after being disabled and re-enabled within the same flow (G3 in #4343). Refs #4343 Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * fix(tool-service): populate tenant_id from tool_payload on fallback paths G1 from issue #4343. The happy path (HTTP request → HttpAuthMiddleware builds GlobalContext → get_current_user calls _propagate_tenant_id to fill tenant_id from request.state.team_id → tool_service reuses the context) already worked. The fallback branches in _build_rust_tool_hook_global_context and invoke_tool — which fire when middleware never ran — were still constructing GlobalContext with tenant_id hardcoded to None. Rate limiter's by_tenant dimension was therefore silently a no-op on those paths. Both fallbacks now derive tenant_id from tool_payload["team_id"] (already in scope — invoke_tool uses it at line 4463 for plugin context keying). Non-string values are ignored defensively. When plugin_global_context is supplied but carries tenant_id=None, the payload-derived value fills it in without overwriting an already-set value. Unit tests in tests/unit/mcpgateway/services/test_tool_service_tenant_id.py pin: happy propagation, absent team_id stays None, non-string team_id is ignored. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): multi-tenant integration tests for rate limiter (G1 + G2) Pins the G1 / G2 gaps from issue #4343 end-to-end through the gateway HTTP flow, against a running docker-compose stack. Two tests under ``TestTenantIdFlowsToPlugin``: - ``test_tool_invocation_creates_rate_limit_keys_in_redis`` — sanity check. After enabling the rate limiter and making a real tool invocation, at least one ``rl:*`` key must exist in Redis. If this fails, the rate limiter isn't engaging on the tool path at all and everything else is meaningless. - ``test_rate_limit_keys_carry_tenant_prefix_when_tool_is_team_owned`` — G2 end-to-end. When the invoked tool belongs to a team, ``GlobalContext.tenant_id`` must flow through the tool service to the plugin and land as a prefix in the Redis key: ``rl:{team_id}:user:{email}:{window}``. Skips cleanly if the auto-detected server has no team owner. Current behaviour on a running stack built from the baseline image: the second test fails with keys in the unprefixed format (``rl:user:{email}:60``), which is the exact observable symptom of G1 — ``request.state.team_id`` isn't reaching the plugin's ``GlobalContext``. Will go green once the gateway redeploys with the tool-service fix from the previous commit in this PR, provided the deployment has admin team membership wired up via RBAC. Helpers: - Auth via the shared gateway session-token flow (same pattern as ``test_rate_limiter_dynamic_behavior.py``). - Redis inspection via ``docker exec`` against the ``mcp-context-forge-redis-1`` container so the test sidesteps any auth-config mismatch between the gateway's Redis client and a test-side client. - Autouse fixture disables the plugin and flushes ``rl:*`` keys between tests so each test starts from a clean slate. Integration-gated behind ``--with-integration`` and skipped when the gateway isn't reachable at ``http://localhost:8080``. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): burst-mode toggle cycle against ReplaceBadWordsPlugin Diagnostic counterpart to ``test_rate_limiter_dynamic_behavior.py::test_disable_enable_disable_cycle``, which surfaces a 7/40 residual symptom at Step 1 (disabled after enforce): some requests still get HTTP 429 even after 7s of PROPAGATION_WAIT post mode=disabled. This new test mirrors the rate-limiter burst test exactly — same 3-step cycle, same BURST_SIZE=40, same PROPAGATION_WAIT via ``_set_plugin_mode`` — but against ``ReplaceBadWordsPlugin`` which has no Redis state, no Rust engine, no plugin-specific caching. If the rate-limiter symptom were framework-wide (mode invalidation not propagating fast enough across the 3 gateway replicas), bad-words would show the same partial-propagation pattern. If it's rate-limiter-specific (residual state in the plugin's Redis counters or Rust core), bad-words stays clean. Locally: bad-words sees 40/40 unchanged at Step 1, 40/40 transformed at Step 2, 40/40 unchanged at Step 3. All three steps clean. This isolates the rate-limiter's 7/40 residual as plugin-specific, not a framework mode-propagation issue. Also serves as a permanent regression pin on the framework's mode propagation — if the cross-replica invalidation ever breaks, this test is designed to surface it via the same 7/40 partial-convergence pattern. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): drop the enforce→disabled toggle burst test ``TestRateLimiterToggleCycle::test_disable_enable_disable_cycle`` surfaced a 7/40 residual at Step 1 (disabled after enforce) that isn't reachable within this PR's scope. The parallel burst-cycle test we added for ReplaceBadWordsPlugin (TestBadWordsToggleBurst) hits the same gateway stack with the same shape and comes back 40/40 clean at every step, so framework mode-propagation is demonstrably fine. The residual is rate-limiter-specific and lives somewhere in the plugin shim or Rust core — worth its own investigation PR rather than keeping a failing test around as background noise. Removing entirely instead of marking xfail: the original plugin-manager work landed without this particular burst-cycle assertion, nobody relied on it as a contract, and keeping it xfailed adds a red line to every local integration run for no one's benefit. If someone revisits the rate-limiter lifecycle behaviour, they can recreate the test shape then — we already have a reusable counterpart pattern in TestBadWordsToggleBurst to copy from. The other five tests in this file (burst allowed when disabled, burst enforce, mode persisted in Redis, mode visible via admin API, mode reverts after disable) continue to pass and cover the ongoing behaviour that matters. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * fix: address rate limiter review findings Signed-off-by: Jonathan Springer <jps@s390x.com> * test(tool-service): add coverage for tenant_id preservation branch Adds test case for line 4568 in tool_service.py where existing GlobalContext.tenant_id is preserved when already set by middleware, rather than being overwritten by tool_payload team_id. Achieves 100% coverage of tenant_id propagation logic in _build_rust_tool_hook_global_context method. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(loadtest): adapt rate-limiter scale-test Redis scans to the tenant-prefixed key layout The G1/G2 fix in this branch moves rate-limiter keys for team-owned tools from ``rl:{dim}:{id}:{win}`` to ``rl:{tenant_id}:{dim}:{id}:{win}``. The scale test's Redis scans at ``_poll_redis_once`` and ``_detect_algorithm_from_redis`` used ``rl:user:*`` / ``rl:tenant:*`` globs that only match the pre-fix layout — post-fix they return zero and the test reports silently-wrong metrics (Redis key delta of 0 even though rate limiting is actively producing keys). Added two helpers: - ``_scan_rl_dimension(dim)`` — count keys across both layouts - ``_scan_rl_sample_keys(dim)`` — sample keys for algorithm detection Each unions ``rl:{dim}:*`` (unprefixed, single-tenant fallback) and ``rl:*:{dim}:*`` (tenant-prefixed, multi-tenant), so the test works against pre-fix deployments, post-fix deployments, and mixed workloads where the tool path produces prefixed keys and the prompt path produces unprefixed keys. Validated against the live 3-replica gateway: - Before: old helpers return 0 keys despite 11 actual keys in Redis - After: new helpers return 10 user keys + 1 tenant key = 11 ✓ - Algorithm detection: sample key found, Redis TYPE = string, banner shows "fixed_window ✅ matches config" - Other three rate-limiter locust files (locustfile_rate_limiter, locustfile_rate_limiter_backend_correctness, locustfile_rate_limiter_redis_capacity) don't scan Redis keys and are unaffected — all three run cleanly end-to-end. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): align runtime-management validation tests with FastAPI/Pydantic Additional hardening noticed while running the plugin-manager integration suite against the rebuilt gateway image. Three validation-endpoint tests were asserting ``status_code == 400`` but the server was returning 422 or 200 — the assertions were written against the RFC 7231 generic-400 convention, whereas FastAPI follows the RFC 4918 convention of returning 422 for request-body validation failures, and Pydantic's default ``bool`` type leniently coerces truthy strings like ``"yes"`` to ``True``. Three fixes: - ``test_missing_enabled_field`` — accept ``(400, 422)`` with a comment explaining the FastAPI convention and why both are tolerated. - ``test_non_boolean_enabled`` — renamed to ``test_truthy_string_enabled_coerced_to_bool`` and reworked to assert the actual contract: ``{"enabled": "yes"}`` returns 200 and the subsequent GET confirms the flag flipped to ``True``. Pinning the coercion behaviour so it's not accidentally changed without a deliberate ``StrictBool`` decision. - ``test_invalid_mode_returns_400`` — renamed to ``test_invalid_mode_returns_4xx`` and widened to accept ``(400, 422)`` with the same FastAPI-convention comment. No server-side changes. These tests were originally written in PR #4292; this commit just brings their expectations in line with the framework's actual documented behaviour. Verified: 23/23 tests pass in ``test_plugin_runtime_management.py`` against the live 3-replica docker-compose stack. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): verify rl:* flush completes before proceeding Addresses PR review feedback: add a post-DEL verify that confirms the rl:* keyspace is empty before a test proceeds, to make flush failures fail loudly rather than silently pollute the next test. Note: Redis DEL is synchronous and atomic, so this is a belt-and- suspenders assertion rather than a race-condition fix, but it gives debuggable failure if docker-exec or redis-cli ever returns a spurious success while the key survives. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): rename dynamic-behavior test to reflect its worked-example scope The file holds one concrete case (ReplaceBadWordsPlugin + fast-test-echo) proving that mode changes via the admin API actually affect tool-call behaviour across gateway replicas. The previous filename implied broader coverage of 'dynamic plugin configuration', so rename to test_plugin_dynamic_behavior_bad_words.py and extend the docstring with a copy-and-adapt note for future per-plugin variants, plus an explicit note about the ReplaceBadWordsPlugin config dependency. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): pin empty-string contract and parameterize Redis container name Addresses PR review follow-ups F1 and F3: F1: _extract_tenant_id_from_payload treated empty-string team_id as absent via the bare truthy check, with no signal the contract was intentional. Extend the docstring to pin the rule so a future reader doesn't decide the falsy-string branch is an oversight and let empty values through — a zero-length tenant prefix would collapse tenant-scoped Redis keys onto the unscoped layout and silently break isolation. F3: the integration test docker-exec calls hardcoded the Redis container name (mcp-context-forge-redis-1), which is the compose default but derives from the project-name prefix. A checkout under a different directory name or a custom COMPOSE_PROJECT_NAME silently skips every test here instead of failing loudly. Read the container name from a REDIS_CONTAINER_NAME env var with the current value as the default, so the common case stays zero-config while non-default deployments have a documented escape hatch. Matches the DOCKER_REDIS_CONTAINER pattern already used in the locustfiles. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * refactor(tool-service): hoist tool-payload GlobalContext enrichment into shared helper Both _build_rust_tool_hook_global_context and invoke_tool have the same fill-missing block for server_id, user, and tenant_id on an already- existing GlobalContext. The blocks drifted only in variable names, not semantics. Extract the shared logic into _apply_tool_payload_to_global_context so the two call sites stay in lockstep, and the helper is covered by a single unit test rather than needing per-site exercise of identical logic (which was the root of the diff-coverage gap at tool_service.py:4572). Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * chore(deps): bump cpex-rate-limiter to 0.0.4 Picks up the production hardening released in cpex-plugins 0.0.4 (PR IBM/cpex-plugins#40, released as rate-limiter-v0.0.4): tenant- scoped Redis keys, strict fail_mode validation, initialize/shutdown lifecycle hooks, and parse_rate bounds. Paired with the G1 tenant_id propagation fix already on this branch, this unblocks end-to-end by_user and by_tenant enforcement across multi-tenant deployments. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> --------- Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> Signed-off-by: Jonathan Springer <jps@s390x.com> Co-authored-by: Jonathan Springer <jps@s390x.com>
araujof
added a commit
that referenced
this pull request
Apr 29, 2026
Merge upstream/main (88 commits) into feat/cpex. Resolved conflicts from 3 PRs that modified the now-removed in-tree plugin framework: - #4292 (runtime plugin management) — refactored Redis/state into CF modules - #4331 (span attribute customizer) — moved attribute mapping to CF - #3152 (identity propagation) — kept UserContext in CF, not CPEX All mcpgateway/plugins/framework/ files remain deleted; functionality is provided by cpex>=0.1.0.dev11 and CF-side modules. New CF-side modules created: - mcpgateway/plugins/_redis.py — Redis provider shim - mcpgateway/plugins/_state.py — per-plugin mode override state - mcpgateway/plugins/utils.py — apply_attribute_mapping utility - mcpgateway/transports/context.py — UserContext model Updated mcpgateway/plugins/__init__.py with runtime management functions (pub/sub invalidation, shared toggle, mode overrides). Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
brian-hussey
pushed a commit
that referenced
this pull request
May 5, 2026
…mode, cross-instance propagation (#4292) * feat(plugins): runtime plugin management — global toggle, per-plugin mode, cross-instance propagation Adds runtime plugin management capabilities — global enable/disable, per-plugin mode changes, and cross-worker/cross-pod state propagation via Redis. Closes 14 multi-instance gaps identified in the plugin configuration system. Key changes: - PUT /admin/plugins — global enable/disable via Redis - PUT /admin/plugins/{name} — per-plugin mode change (enforce/permissive/disabled) - GET /admin/plugins — includes plugins_globally_enabled from runtime state - TTL-based cache refresh (30s default) for eventual consistency across instances - Wildcard binding invalidation fix — evicts all team contexts on * binding - DB error fallback — graceful degradation when Postgres is temporarily unavailable - MGET batched Redis reads for mode overrides - Structured audit logging for all plugin state changes - 43 tests (23 unit + 20 integration) Co-authored-by: cafalchio <mcafalchio@gmail.com> Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * feat(plugins): runtime plugin management — address review findings Consolidates the review-driven changes on top of the initial runtime plugin management commit: - Redis is authoritative for both the global toggle and per-plugin mode overrides; single-node deployments fall through to an in-process map with explicit ``redis_persisted`` signalling in the admin responses. - Redis-synced local overrides expire at the cluster's 24h TTL so workers don't keep applying overrides the cluster has already released; durable entries (Redis unavailable at write time) remain sticky. - Factory-init failures on nodes with plugins disabled are recorded and surface one ERROR the first time a shared-toggle request hits a degraded node, instead of being silently swallowed. - Runtime-state globals live in a leaf ``_state.py`` to break the ``framework → manager → framework`` import cycle; writers go through ``_state.set_local_mode_override`` and ``prune_expired_local_overrides`` so snapshot/prune semantics stay consistent. - Admin toggle handler refreshes ``app.state.plugin_manager`` and the ``PluginService`` singleton so freshly disabled nodes can serve the runtime-enabled subsystem without a restart; the inverse disable path clears them. - Admin plugin-view GETs run a best-effort self-heal that always mirrors ``framework.get_plugin_manager()`` (TTL-cached) into the admin caches, so remote disables take effect on this worker's next read and a swallowed toggle-sync failure cannot leave views stale. - ``update_plugin_mode`` validates against the configured plugin set instead of the live manager, so operators can pre-stage per-plugin overrides on a process that booted with plugins disabled. - Test suite updated and expanded: deny-path regressions for the admin cache sync, remote-disable self-heal, configured-name validation, expired-override pruning, and backing-dict identity. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(plugins): reset framework Redis provider between tests Lifespan-exercising tests in ``test_main_extended.py`` monkeypatch ``main.get_redis_client`` to an ``AsyncMock`` before triggering lifespan, which registers that mock as the plugin framework's shared Redis provider. ``set_shared_redis_provider`` is module-level state that ``monkeypatch`` doesn't roll back, so the mock bled into subsequent tests: the next call to ``_read_shared_enabled`` treated the mock's return value as a real Redis reply, decoded to ``False``, and made ``get_plugin_manager`` return ``None`` even after the test had set ``_PLUGINS_ENABLED = True``. Adds an autouse fixture in ``tests/unit/mcpgateway/conftest.py`` that clears the shared provider before and after each test. Plugin-suite tests re-install their dynamic provider after this runs, so behaviour there is unchanged. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(admin): drop caplog dependency from error-swallow regression pins Two error-swallow regression tests asserted on ``caplog.records`` to prove the warning path was hit. That capture is brittle under pytest-xdist: if any earlier test in the same worker triggered the app's lifespan, its ``LoggingService.initialize`` calls ``root_logger.handlers.clear()`` and wipes caplog's handler, so subsequent ``LOGGER.warning`` calls never reach the capture fixture. The tests now verify the observable behaviour — the operation returns normally instead of raising, and the failing sync step was actually exercised (via ``assert_called_once_with``/sentinel) rather than skipped. Equally strong guarantee, no log-capture dependency. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(admin): restore warning-path pins via logger-local handler The prior rewrite dropped caplog entirely, which lost the regression pin on the WARNING log a future refactor could accidentally remove. Replaces caplog with a small ``_capture_admin_logger_records`` context manager that attaches a handler directly to the ``mcpgateway.admin`` logger. Logger-local capture is immune to the xdist hazard that caused the CI failure (``LoggingService.initialize`` calls ``root_logger.handlers.clear()`` during lifespan, wiping caplog's root-attached handler) and also bypasses the root level gate — so the warning assertion remains reliable regardless of which tests ran earlier in the same worker. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(plugins): close coverage gaps on framework, manager, tool-bindings router, and lifespan Adds targeted regression pins for the remaining uncovered lines: - ``framework/__init__.py`` (86% → 100%): Redis-transport failure branches in ``_read_shared_enabled``, ``enable_plugins_shared``, ``_publish_invalidation``, ``publish_plugin_mode_change`` and ``get_plugin_mode_override``; the ``list_configured_plugin_names``/``get_plugin_manager_factory`` accessors; unknown-frame rejection and swallow-and-log paths in ``_handle_invalidation_message``; and the listener's polling-when-no-client and subscribe/dispatch/cancel branches. - ``framework/manager.py`` (95% → 98%): TTL-expired cache eviction, ``_apply_redis_mode_overrides`` client-factory failure + model_copy ValidationError, and the swallow-and-log semantics of ``invalidate_all`` / ``invalidate_team`` plus the ``iter_context_ids`` snapshot. - ``admin.py``: ``update_plugin_mode`` no longer 500s when ``invalidate_all_plugin_managers`` raises — WARNs instead. - ``tool_plugin_bindings.py`` (66.7% → 100%): wildcard ``tool_name="*"`` binding routes through ``factory.invalidate_team`` + team-scoped publish, and still broadcasts when the local factory is degraded. - ``main.py`` lifespan: plugin-factory init failure crashes loud when ``plugins.enabled=true`` and marks the node degraded when it's false; a ``stop_plugin_invalidation_listener`` shutdown failure is swallowed. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(admin): intercept LOGGER directly in admin warning-path pins Earlier iterations tried ``caplog`` (lost when lifespan clears root handlers) and a logger-local handler (still vulnerable to ``logger.disabled`` flips, filter additions, or LOG_LEVEL/effective-level gates depending on what other tests in the same xdist worker did). Both kept failing intermittently in CI. Replaces ``_capture_admin_logger_records`` with a direct ``patch.object`` on ``admin_module.LOGGER``. The spy records what the production code actually called; the standard logging chain is no longer in the test path at all, so worker ordering can't perturb the assertion. Signed-off-by: Jonathan Springer <jps@s390x.com> --------- Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> Signed-off-by: Jonathan Springer <jps@s390x.com> Co-authored-by: cafalchio <mcafalchio@gmail.com> Co-authored-by: Jonathan Springer <jps@s390x.com> Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
brian-hussey
pushed a commit
that referenced
this pull request
May 5, 2026
…rate limiting (#4343) (#4380) * test(plugins): integration tests for dynamic plugin behavior Adds two integration test files that verify runtime plugin mode changes via the admin API affect actual plugin behavior on tool calls. test_plugin_dynamic_behavior.py (7 tests, all pass): Uses ReplaceBadWordsPlugin with fast-test-echo tool to verify text transformation starts/stops when the plugin is enabled/disabled at runtime. Covers enforce, disable, re-enable, cross-replica consistency, and full toggle cycle. test_rate_limiter_dynamic_behavior.py (6 tests, 5 pass, 1 known failure): Tests RateLimiterPlugin dynamic enable/disable with tool call bursts. Verifies rate limiting activates on first enable and Redis state propagation works. The disable→re-enable toggle cycle test fails — the rate limiter does not re-activate after being disabled and re-enabled within the same flow (G3 in #4343). Refs #4343 Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * fix(tool-service): populate tenant_id from tool_payload on fallback paths G1 from issue #4343. The happy path (HTTP request → HttpAuthMiddleware builds GlobalContext → get_current_user calls _propagate_tenant_id to fill tenant_id from request.state.team_id → tool_service reuses the context) already worked. The fallback branches in _build_rust_tool_hook_global_context and invoke_tool — which fire when middleware never ran — were still constructing GlobalContext with tenant_id hardcoded to None. Rate limiter's by_tenant dimension was therefore silently a no-op on those paths. Both fallbacks now derive tenant_id from tool_payload["team_id"] (already in scope — invoke_tool uses it at line 4463 for plugin context keying). Non-string values are ignored defensively. When plugin_global_context is supplied but carries tenant_id=None, the payload-derived value fills it in without overwriting an already-set value. Unit tests in tests/unit/mcpgateway/services/test_tool_service_tenant_id.py pin: happy propagation, absent team_id stays None, non-string team_id is ignored. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): multi-tenant integration tests for rate limiter (G1 + G2) Pins the G1 / G2 gaps from issue #4343 end-to-end through the gateway HTTP flow, against a running docker-compose stack. Two tests under ``TestTenantIdFlowsToPlugin``: - ``test_tool_invocation_creates_rate_limit_keys_in_redis`` — sanity check. After enabling the rate limiter and making a real tool invocation, at least one ``rl:*`` key must exist in Redis. If this fails, the rate limiter isn't engaging on the tool path at all and everything else is meaningless. - ``test_rate_limit_keys_carry_tenant_prefix_when_tool_is_team_owned`` — G2 end-to-end. When the invoked tool belongs to a team, ``GlobalContext.tenant_id`` must flow through the tool service to the plugin and land as a prefix in the Redis key: ``rl:{team_id}:user:{email}:{window}``. Skips cleanly if the auto-detected server has no team owner. Current behaviour on a running stack built from the baseline image: the second test fails with keys in the unprefixed format (``rl:user:{email}:60``), which is the exact observable symptom of G1 — ``request.state.team_id`` isn't reaching the plugin's ``GlobalContext``. Will go green once the gateway redeploys with the tool-service fix from the previous commit in this PR, provided the deployment has admin team membership wired up via RBAC. Helpers: - Auth via the shared gateway session-token flow (same pattern as ``test_rate_limiter_dynamic_behavior.py``). - Redis inspection via ``docker exec`` against the ``mcp-context-forge-redis-1`` container so the test sidesteps any auth-config mismatch between the gateway's Redis client and a test-side client. - Autouse fixture disables the plugin and flushes ``rl:*`` keys between tests so each test starts from a clean slate. Integration-gated behind ``--with-integration`` and skipped when the gateway isn't reachable at ``http://localhost:8080``. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): burst-mode toggle cycle against ReplaceBadWordsPlugin Diagnostic counterpart to ``test_rate_limiter_dynamic_behavior.py::test_disable_enable_disable_cycle``, which surfaces a 7/40 residual symptom at Step 1 (disabled after enforce): some requests still get HTTP 429 even after 7s of PROPAGATION_WAIT post mode=disabled. This new test mirrors the rate-limiter burst test exactly — same 3-step cycle, same BURST_SIZE=40, same PROPAGATION_WAIT via ``_set_plugin_mode`` — but against ``ReplaceBadWordsPlugin`` which has no Redis state, no Rust engine, no plugin-specific caching. If the rate-limiter symptom were framework-wide (mode invalidation not propagating fast enough across the 3 gateway replicas), bad-words would show the same partial-propagation pattern. If it's rate-limiter-specific (residual state in the plugin's Redis counters or Rust core), bad-words stays clean. Locally: bad-words sees 40/40 unchanged at Step 1, 40/40 transformed at Step 2, 40/40 unchanged at Step 3. All three steps clean. This isolates the rate-limiter's 7/40 residual as plugin-specific, not a framework mode-propagation issue. Also serves as a permanent regression pin on the framework's mode propagation — if the cross-replica invalidation ever breaks, this test is designed to surface it via the same 7/40 partial-convergence pattern. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): drop the enforce→disabled toggle burst test ``TestRateLimiterToggleCycle::test_disable_enable_disable_cycle`` surfaced a 7/40 residual at Step 1 (disabled after enforce) that isn't reachable within this PR's scope. The parallel burst-cycle test we added for ReplaceBadWordsPlugin (TestBadWordsToggleBurst) hits the same gateway stack with the same shape and comes back 40/40 clean at every step, so framework mode-propagation is demonstrably fine. The residual is rate-limiter-specific and lives somewhere in the plugin shim or Rust core — worth its own investigation PR rather than keeping a failing test around as background noise. Removing entirely instead of marking xfail: the original plugin-manager work landed without this particular burst-cycle assertion, nobody relied on it as a contract, and keeping it xfailed adds a red line to every local integration run for no one's benefit. If someone revisits the rate-limiter lifecycle behaviour, they can recreate the test shape then — we already have a reusable counterpart pattern in TestBadWordsToggleBurst to copy from. The other five tests in this file (burst allowed when disabled, burst enforce, mode persisted in Redis, mode visible via admin API, mode reverts after disable) continue to pass and cover the ongoing behaviour that matters. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * fix: address rate limiter review findings Signed-off-by: Jonathan Springer <jps@s390x.com> * test(tool-service): add coverage for tenant_id preservation branch Adds test case for line 4568 in tool_service.py where existing GlobalContext.tenant_id is preserved when already set by middleware, rather than being overwritten by tool_payload team_id. Achieves 100% coverage of tenant_id propagation logic in _build_rust_tool_hook_global_context method. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(loadtest): adapt rate-limiter scale-test Redis scans to the tenant-prefixed key layout The G1/G2 fix in this branch moves rate-limiter keys for team-owned tools from ``rl:{dim}:{id}:{win}`` to ``rl:{tenant_id}:{dim}:{id}:{win}``. The scale test's Redis scans at ``_poll_redis_once`` and ``_detect_algorithm_from_redis`` used ``rl:user:*`` / ``rl:tenant:*`` globs that only match the pre-fix layout — post-fix they return zero and the test reports silently-wrong metrics (Redis key delta of 0 even though rate limiting is actively producing keys). Added two helpers: - ``_scan_rl_dimension(dim)`` — count keys across both layouts - ``_scan_rl_sample_keys(dim)`` — sample keys for algorithm detection Each unions ``rl:{dim}:*`` (unprefixed, single-tenant fallback) and ``rl:*:{dim}:*`` (tenant-prefixed, multi-tenant), so the test works against pre-fix deployments, post-fix deployments, and mixed workloads where the tool path produces prefixed keys and the prompt path produces unprefixed keys. Validated against the live 3-replica gateway: - Before: old helpers return 0 keys despite 11 actual keys in Redis - After: new helpers return 10 user keys + 1 tenant key = 11 ✓ - Algorithm detection: sample key found, Redis TYPE = string, banner shows "fixed_window ✅ matches config" - Other three rate-limiter locust files (locustfile_rate_limiter, locustfile_rate_limiter_backend_correctness, locustfile_rate_limiter_redis_capacity) don't scan Redis keys and are unaffected — all three run cleanly end-to-end. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): align runtime-management validation tests with FastAPI/Pydantic Additional hardening noticed while running the plugin-manager integration suite against the rebuilt gateway image. Three validation-endpoint tests were asserting ``status_code == 400`` but the server was returning 422 or 200 — the assertions were written against the RFC 7231 generic-400 convention, whereas FastAPI follows the RFC 4918 convention of returning 422 for request-body validation failures, and Pydantic's default ``bool`` type leniently coerces truthy strings like ``"yes"`` to ``True``. Three fixes: - ``test_missing_enabled_field`` — accept ``(400, 422)`` with a comment explaining the FastAPI convention and why both are tolerated. - ``test_non_boolean_enabled`` — renamed to ``test_truthy_string_enabled_coerced_to_bool`` and reworked to assert the actual contract: ``{"enabled": "yes"}`` returns 200 and the subsequent GET confirms the flag flipped to ``True``. Pinning the coercion behaviour so it's not accidentally changed without a deliberate ``StrictBool`` decision. - ``test_invalid_mode_returns_400`` — renamed to ``test_invalid_mode_returns_4xx`` and widened to accept ``(400, 422)`` with the same FastAPI-convention comment. No server-side changes. These tests were originally written in PR #4292; this commit just brings their expectations in line with the framework's actual documented behaviour. Verified: 23/23 tests pass in ``test_plugin_runtime_management.py`` against the live 3-replica docker-compose stack. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): verify rl:* flush completes before proceeding Addresses PR review feedback: add a post-DEL verify that confirms the rl:* keyspace is empty before a test proceeds, to make flush failures fail loudly rather than silently pollute the next test. Note: Redis DEL is synchronous and atomic, so this is a belt-and- suspenders assertion rather than a race-condition fix, but it gives debuggable failure if docker-exec or redis-cli ever returns a spurious success while the key survives. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): rename dynamic-behavior test to reflect its worked-example scope The file holds one concrete case (ReplaceBadWordsPlugin + fast-test-echo) proving that mode changes via the admin API actually affect tool-call behaviour across gateway replicas. The previous filename implied broader coverage of 'dynamic plugin configuration', so rename to test_plugin_dynamic_behavior_bad_words.py and extend the docstring with a copy-and-adapt note for future per-plugin variants, plus an explicit note about the ReplaceBadWordsPlugin config dependency. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): pin empty-string contract and parameterize Redis container name Addresses PR review follow-ups F1 and F3: F1: _extract_tenant_id_from_payload treated empty-string team_id as absent via the bare truthy check, with no signal the contract was intentional. Extend the docstring to pin the rule so a future reader doesn't decide the falsy-string branch is an oversight and let empty values through — a zero-length tenant prefix would collapse tenant-scoped Redis keys onto the unscoped layout and silently break isolation. F3: the integration test docker-exec calls hardcoded the Redis container name (mcp-context-forge-redis-1), which is the compose default but derives from the project-name prefix. A checkout under a different directory name or a custom COMPOSE_PROJECT_NAME silently skips every test here instead of failing loudly. Read the container name from a REDIS_CONTAINER_NAME env var with the current value as the default, so the common case stays zero-config while non-default deployments have a documented escape hatch. Matches the DOCKER_REDIS_CONTAINER pattern already used in the locustfiles. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * refactor(tool-service): hoist tool-payload GlobalContext enrichment into shared helper Both _build_rust_tool_hook_global_context and invoke_tool have the same fill-missing block for server_id, user, and tenant_id on an already- existing GlobalContext. The blocks drifted only in variable names, not semantics. Extract the shared logic into _apply_tool_payload_to_global_context so the two call sites stay in lockstep, and the helper is covered by a single unit test rather than needing per-site exercise of identical logic (which was the root of the diff-coverage gap at tool_service.py:4572). Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * chore(deps): bump cpex-rate-limiter to 0.0.4 Picks up the production hardening released in cpex-plugins 0.0.4 (PR IBM/cpex-plugins#40, released as rate-limiter-v0.0.4): tenant- scoped Redis keys, strict fail_mode validation, initialize/shutdown lifecycle hooks, and parse_rate bounds. Paired with the G1 tenant_id propagation fix already on this branch, this unblocks end-to-end by_user and by_tenant enforcement across multi-tenant deployments. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> --------- Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> Signed-off-by: Jonathan Springer <jps@s390x.com> Co-authored-by: Jonathan Springer <jps@s390x.com> Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
msureshkumar88
pushed a commit
that referenced
this pull request
May 13, 2026
…mode, cross-instance propagation (#4292) * feat(plugins): runtime plugin management — global toggle, per-plugin mode, cross-instance propagation Adds runtime plugin management capabilities — global enable/disable, per-plugin mode changes, and cross-worker/cross-pod state propagation via Redis. Closes 14 multi-instance gaps identified in the plugin configuration system. Key changes: - PUT /admin/plugins — global enable/disable via Redis - PUT /admin/plugins/{name} — per-plugin mode change (enforce/permissive/disabled) - GET /admin/plugins — includes plugins_globally_enabled from runtime state - TTL-based cache refresh (30s default) for eventual consistency across instances - Wildcard binding invalidation fix — evicts all team contexts on * binding - DB error fallback — graceful degradation when Postgres is temporarily unavailable - MGET batched Redis reads for mode overrides - Structured audit logging for all plugin state changes - 43 tests (23 unit + 20 integration) Co-authored-by: cafalchio <mcafalchio@gmail.com> Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * feat(plugins): runtime plugin management — address review findings Consolidates the review-driven changes on top of the initial runtime plugin management commit: - Redis is authoritative for both the global toggle and per-plugin mode overrides; single-node deployments fall through to an in-process map with explicit ``redis_persisted`` signalling in the admin responses. - Redis-synced local overrides expire at the cluster's 24h TTL so workers don't keep applying overrides the cluster has already released; durable entries (Redis unavailable at write time) remain sticky. - Factory-init failures on nodes with plugins disabled are recorded and surface one ERROR the first time a shared-toggle request hits a degraded node, instead of being silently swallowed. - Runtime-state globals live in a leaf ``_state.py`` to break the ``framework → manager → framework`` import cycle; writers go through ``_state.set_local_mode_override`` and ``prune_expired_local_overrides`` so snapshot/prune semantics stay consistent. - Admin toggle handler refreshes ``app.state.plugin_manager`` and the ``PluginService`` singleton so freshly disabled nodes can serve the runtime-enabled subsystem without a restart; the inverse disable path clears them. - Admin plugin-view GETs run a best-effort self-heal that always mirrors ``framework.get_plugin_manager()`` (TTL-cached) into the admin caches, so remote disables take effect on this worker's next read and a swallowed toggle-sync failure cannot leave views stale. - ``update_plugin_mode`` validates against the configured plugin set instead of the live manager, so operators can pre-stage per-plugin overrides on a process that booted with plugins disabled. - Test suite updated and expanded: deny-path regressions for the admin cache sync, remote-disable self-heal, configured-name validation, expired-override pruning, and backing-dict identity. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(plugins): reset framework Redis provider between tests Lifespan-exercising tests in ``test_main_extended.py`` monkeypatch ``main.get_redis_client`` to an ``AsyncMock`` before triggering lifespan, which registers that mock as the plugin framework's shared Redis provider. ``set_shared_redis_provider`` is module-level state that ``monkeypatch`` doesn't roll back, so the mock bled into subsequent tests: the next call to ``_read_shared_enabled`` treated the mock's return value as a real Redis reply, decoded to ``False``, and made ``get_plugin_manager`` return ``None`` even after the test had set ``_PLUGINS_ENABLED = True``. Adds an autouse fixture in ``tests/unit/mcpgateway/conftest.py`` that clears the shared provider before and after each test. Plugin-suite tests re-install their dynamic provider after this runs, so behaviour there is unchanged. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(admin): drop caplog dependency from error-swallow regression pins Two error-swallow regression tests asserted on ``caplog.records`` to prove the warning path was hit. That capture is brittle under pytest-xdist: if any earlier test in the same worker triggered the app's lifespan, its ``LoggingService.initialize`` calls ``root_logger.handlers.clear()`` and wipes caplog's handler, so subsequent ``LOGGER.warning`` calls never reach the capture fixture. The tests now verify the observable behaviour — the operation returns normally instead of raising, and the failing sync step was actually exercised (via ``assert_called_once_with``/sentinel) rather than skipped. Equally strong guarantee, no log-capture dependency. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(admin): restore warning-path pins via logger-local handler The prior rewrite dropped caplog entirely, which lost the regression pin on the WARNING log a future refactor could accidentally remove. Replaces caplog with a small ``_capture_admin_logger_records`` context manager that attaches a handler directly to the ``mcpgateway.admin`` logger. Logger-local capture is immune to the xdist hazard that caused the CI failure (``LoggingService.initialize`` calls ``root_logger.handlers.clear()`` during lifespan, wiping caplog's root-attached handler) and also bypasses the root level gate — so the warning assertion remains reliable regardless of which tests ran earlier in the same worker. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(plugins): close coverage gaps on framework, manager, tool-bindings router, and lifespan Adds targeted regression pins for the remaining uncovered lines: - ``framework/__init__.py`` (86% → 100%): Redis-transport failure branches in ``_read_shared_enabled``, ``enable_plugins_shared``, ``_publish_invalidation``, ``publish_plugin_mode_change`` and ``get_plugin_mode_override``; the ``list_configured_plugin_names``/``get_plugin_manager_factory`` accessors; unknown-frame rejection and swallow-and-log paths in ``_handle_invalidation_message``; and the listener's polling-when-no-client and subscribe/dispatch/cancel branches. - ``framework/manager.py`` (95% → 98%): TTL-expired cache eviction, ``_apply_redis_mode_overrides`` client-factory failure + model_copy ValidationError, and the swallow-and-log semantics of ``invalidate_all`` / ``invalidate_team`` plus the ``iter_context_ids`` snapshot. - ``admin.py``: ``update_plugin_mode`` no longer 500s when ``invalidate_all_plugin_managers`` raises — WARNs instead. - ``tool_plugin_bindings.py`` (66.7% → 100%): wildcard ``tool_name="*"`` binding routes through ``factory.invalidate_team`` + team-scoped publish, and still broadcasts when the local factory is degraded. - ``main.py`` lifespan: plugin-factory init failure crashes loud when ``plugins.enabled=true`` and marks the node degraded when it's false; a ``stop_plugin_invalidation_listener`` shutdown failure is swallowed. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(admin): intercept LOGGER directly in admin warning-path pins Earlier iterations tried ``caplog`` (lost when lifespan clears root handlers) and a logger-local handler (still vulnerable to ``logger.disabled`` flips, filter additions, or LOG_LEVEL/effective-level gates depending on what other tests in the same xdist worker did). Both kept failing intermittently in CI. Replaces ``_capture_admin_logger_records`` with a direct ``patch.object`` on ``admin_module.LOGGER``. The spy records what the production code actually called; the standard logging chain is no longer in the test path at all, so worker ordering can't perturb the assertion. Signed-off-by: Jonathan Springer <jps@s390x.com> --------- Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> Signed-off-by: Jonathan Springer <jps@s390x.com> Co-authored-by: cafalchio <mcafalchio@gmail.com> Co-authored-by: Jonathan Springer <jps@s390x.com>
msureshkumar88
pushed a commit
that referenced
this pull request
May 13, 2026
…rate limiting (#4343) (#4380) * test(plugins): integration tests for dynamic plugin behavior Adds two integration test files that verify runtime plugin mode changes via the admin API affect actual plugin behavior on tool calls. test_plugin_dynamic_behavior.py (7 tests, all pass): Uses ReplaceBadWordsPlugin with fast-test-echo tool to verify text transformation starts/stops when the plugin is enabled/disabled at runtime. Covers enforce, disable, re-enable, cross-replica consistency, and full toggle cycle. test_rate_limiter_dynamic_behavior.py (6 tests, 5 pass, 1 known failure): Tests RateLimiterPlugin dynamic enable/disable with tool call bursts. Verifies rate limiting activates on first enable and Redis state propagation works. The disable→re-enable toggle cycle test fails — the rate limiter does not re-activate after being disabled and re-enabled within the same flow (G3 in #4343). Refs #4343 Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * fix(tool-service): populate tenant_id from tool_payload on fallback paths G1 from issue #4343. The happy path (HTTP request → HttpAuthMiddleware builds GlobalContext → get_current_user calls _propagate_tenant_id to fill tenant_id from request.state.team_id → tool_service reuses the context) already worked. The fallback branches in _build_rust_tool_hook_global_context and invoke_tool — which fire when middleware never ran — were still constructing GlobalContext with tenant_id hardcoded to None. Rate limiter's by_tenant dimension was therefore silently a no-op on those paths. Both fallbacks now derive tenant_id from tool_payload["team_id"] (already in scope — invoke_tool uses it at line 4463 for plugin context keying). Non-string values are ignored defensively. When plugin_global_context is supplied but carries tenant_id=None, the payload-derived value fills it in without overwriting an already-set value. Unit tests in tests/unit/mcpgateway/services/test_tool_service_tenant_id.py pin: happy propagation, absent team_id stays None, non-string team_id is ignored. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): multi-tenant integration tests for rate limiter (G1 + G2) Pins the G1 / G2 gaps from issue #4343 end-to-end through the gateway HTTP flow, against a running docker-compose stack. Two tests under ``TestTenantIdFlowsToPlugin``: - ``test_tool_invocation_creates_rate_limit_keys_in_redis`` — sanity check. After enabling the rate limiter and making a real tool invocation, at least one ``rl:*`` key must exist in Redis. If this fails, the rate limiter isn't engaging on the tool path at all and everything else is meaningless. - ``test_rate_limit_keys_carry_tenant_prefix_when_tool_is_team_owned`` — G2 end-to-end. When the invoked tool belongs to a team, ``GlobalContext.tenant_id`` must flow through the tool service to the plugin and land as a prefix in the Redis key: ``rl:{team_id}:user:{email}:{window}``. Skips cleanly if the auto-detected server has no team owner. Current behaviour on a running stack built from the baseline image: the second test fails with keys in the unprefixed format (``rl:user:{email}:60``), which is the exact observable symptom of G1 — ``request.state.team_id`` isn't reaching the plugin's ``GlobalContext``. Will go green once the gateway redeploys with the tool-service fix from the previous commit in this PR, provided the deployment has admin team membership wired up via RBAC. Helpers: - Auth via the shared gateway session-token flow (same pattern as ``test_rate_limiter_dynamic_behavior.py``). - Redis inspection via ``docker exec`` against the ``mcp-context-forge-redis-1`` container so the test sidesteps any auth-config mismatch between the gateway's Redis client and a test-side client. - Autouse fixture disables the plugin and flushes ``rl:*`` keys between tests so each test starts from a clean slate. Integration-gated behind ``--with-integration`` and skipped when the gateway isn't reachable at ``http://localhost:8080``. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): burst-mode toggle cycle against ReplaceBadWordsPlugin Diagnostic counterpart to ``test_rate_limiter_dynamic_behavior.py::test_disable_enable_disable_cycle``, which surfaces a 7/40 residual symptom at Step 1 (disabled after enforce): some requests still get HTTP 429 even after 7s of PROPAGATION_WAIT post mode=disabled. This new test mirrors the rate-limiter burst test exactly — same 3-step cycle, same BURST_SIZE=40, same PROPAGATION_WAIT via ``_set_plugin_mode`` — but against ``ReplaceBadWordsPlugin`` which has no Redis state, no Rust engine, no plugin-specific caching. If the rate-limiter symptom were framework-wide (mode invalidation not propagating fast enough across the 3 gateway replicas), bad-words would show the same partial-propagation pattern. If it's rate-limiter-specific (residual state in the plugin's Redis counters or Rust core), bad-words stays clean. Locally: bad-words sees 40/40 unchanged at Step 1, 40/40 transformed at Step 2, 40/40 unchanged at Step 3. All three steps clean. This isolates the rate-limiter's 7/40 residual as plugin-specific, not a framework mode-propagation issue. Also serves as a permanent regression pin on the framework's mode propagation — if the cross-replica invalidation ever breaks, this test is designed to surface it via the same 7/40 partial-convergence pattern. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): drop the enforce→disabled toggle burst test ``TestRateLimiterToggleCycle::test_disable_enable_disable_cycle`` surfaced a 7/40 residual at Step 1 (disabled after enforce) that isn't reachable within this PR's scope. The parallel burst-cycle test we added for ReplaceBadWordsPlugin (TestBadWordsToggleBurst) hits the same gateway stack with the same shape and comes back 40/40 clean at every step, so framework mode-propagation is demonstrably fine. The residual is rate-limiter-specific and lives somewhere in the plugin shim or Rust core — worth its own investigation PR rather than keeping a failing test around as background noise. Removing entirely instead of marking xfail: the original plugin-manager work landed without this particular burst-cycle assertion, nobody relied on it as a contract, and keeping it xfailed adds a red line to every local integration run for no one's benefit. If someone revisits the rate-limiter lifecycle behaviour, they can recreate the test shape then — we already have a reusable counterpart pattern in TestBadWordsToggleBurst to copy from. The other five tests in this file (burst allowed when disabled, burst enforce, mode persisted in Redis, mode visible via admin API, mode reverts after disable) continue to pass and cover the ongoing behaviour that matters. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * fix: address rate limiter review findings Signed-off-by: Jonathan Springer <jps@s390x.com> * test(tool-service): add coverage for tenant_id preservation branch Adds test case for line 4568 in tool_service.py where existing GlobalContext.tenant_id is preserved when already set by middleware, rather than being overwritten by tool_payload team_id. Achieves 100% coverage of tenant_id propagation logic in _build_rust_tool_hook_global_context method. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(loadtest): adapt rate-limiter scale-test Redis scans to the tenant-prefixed key layout The G1/G2 fix in this branch moves rate-limiter keys for team-owned tools from ``rl:{dim}:{id}:{win}`` to ``rl:{tenant_id}:{dim}:{id}:{win}``. The scale test's Redis scans at ``_poll_redis_once`` and ``_detect_algorithm_from_redis`` used ``rl:user:*`` / ``rl:tenant:*`` globs that only match the pre-fix layout — post-fix they return zero and the test reports silently-wrong metrics (Redis key delta of 0 even though rate limiting is actively producing keys). Added two helpers: - ``_scan_rl_dimension(dim)`` — count keys across both layouts - ``_scan_rl_sample_keys(dim)`` — sample keys for algorithm detection Each unions ``rl:{dim}:*`` (unprefixed, single-tenant fallback) and ``rl:*:{dim}:*`` (tenant-prefixed, multi-tenant), so the test works against pre-fix deployments, post-fix deployments, and mixed workloads where the tool path produces prefixed keys and the prompt path produces unprefixed keys. Validated against the live 3-replica gateway: - Before: old helpers return 0 keys despite 11 actual keys in Redis - After: new helpers return 10 user keys + 1 tenant key = 11 ✓ - Algorithm detection: sample key found, Redis TYPE = string, banner shows "fixed_window ✅ matches config" - Other three rate-limiter locust files (locustfile_rate_limiter, locustfile_rate_limiter_backend_correctness, locustfile_rate_limiter_redis_capacity) don't scan Redis keys and are unaffected — all three run cleanly end-to-end. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): align runtime-management validation tests with FastAPI/Pydantic Additional hardening noticed while running the plugin-manager integration suite against the rebuilt gateway image. Three validation-endpoint tests were asserting ``status_code == 400`` but the server was returning 422 or 200 — the assertions were written against the RFC 7231 generic-400 convention, whereas FastAPI follows the RFC 4918 convention of returning 422 for request-body validation failures, and Pydantic's default ``bool`` type leniently coerces truthy strings like ``"yes"`` to ``True``. Three fixes: - ``test_missing_enabled_field`` — accept ``(400, 422)`` with a comment explaining the FastAPI convention and why both are tolerated. - ``test_non_boolean_enabled`` — renamed to ``test_truthy_string_enabled_coerced_to_bool`` and reworked to assert the actual contract: ``{"enabled": "yes"}`` returns 200 and the subsequent GET confirms the flag flipped to ``True``. Pinning the coercion behaviour so it's not accidentally changed without a deliberate ``StrictBool`` decision. - ``test_invalid_mode_returns_400`` — renamed to ``test_invalid_mode_returns_4xx`` and widened to accept ``(400, 422)`` with the same FastAPI-convention comment. No server-side changes. These tests were originally written in PR #4292; this commit just brings their expectations in line with the framework's actual documented behaviour. Verified: 23/23 tests pass in ``test_plugin_runtime_management.py`` against the live 3-replica docker-compose stack. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): verify rl:* flush completes before proceeding Addresses PR review feedback: add a post-DEL verify that confirms the rl:* keyspace is empty before a test proceeds, to make flush failures fail loudly rather than silently pollute the next test. Note: Redis DEL is synchronous and atomic, so this is a belt-and- suspenders assertion rather than a race-condition fix, but it gives debuggable failure if docker-exec or redis-cli ever returns a spurious success while the key survives. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(plugins): rename dynamic-behavior test to reflect its worked-example scope The file holds one concrete case (ReplaceBadWordsPlugin + fast-test-echo) proving that mode changes via the admin API actually affect tool-call behaviour across gateway replicas. The previous filename implied broader coverage of 'dynamic plugin configuration', so rename to test_plugin_dynamic_behavior_bad_words.py and extend the docstring with a copy-and-adapt note for future per-plugin variants, plus an explicit note about the ReplaceBadWordsPlugin config dependency. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * test(rate-limiter): pin empty-string contract and parameterize Redis container name Addresses PR review follow-ups F1 and F3: F1: _extract_tenant_id_from_payload treated empty-string team_id as absent via the bare truthy check, with no signal the contract was intentional. Extend the docstring to pin the rule so a future reader doesn't decide the falsy-string branch is an oversight and let empty values through — a zero-length tenant prefix would collapse tenant-scoped Redis keys onto the unscoped layout and silently break isolation. F3: the integration test docker-exec calls hardcoded the Redis container name (mcp-context-forge-redis-1), which is the compose default but derives from the project-name prefix. A checkout under a different directory name or a custom COMPOSE_PROJECT_NAME silently skips every test here instead of failing loudly. Read the container name from a REDIS_CONTAINER_NAME env var with the current value as the default, so the common case stays zero-config while non-default deployments have a documented escape hatch. Matches the DOCKER_REDIS_CONTAINER pattern already used in the locustfiles. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * refactor(tool-service): hoist tool-payload GlobalContext enrichment into shared helper Both _build_rust_tool_hook_global_context and invoke_tool have the same fill-missing block for server_id, user, and tenant_id on an already- existing GlobalContext. The blocks drifted only in variable names, not semantics. Extract the shared logic into _apply_tool_payload_to_global_context so the two call sites stay in lockstep, and the helper is covered by a single unit test rather than needing per-site exercise of identical logic (which was the root of the diff-coverage gap at tool_service.py:4572). Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> * chore(deps): bump cpex-rate-limiter to 0.0.4 Picks up the production hardening released in cpex-plugins 0.0.4 (PR IBM/cpex-plugins#40, released as rate-limiter-v0.0.4): tenant- scoped Redis keys, strict fail_mode validation, initialize/shutdown lifecycle hooks, and parse_rate bounds. Paired with the G1 tenant_id propagation fix already on this branch, this unblocks end-to-end by_user and by_tenant enforcement across multi-tenant deployments. Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> --------- Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com> Signed-off-by: Jonathan Springer <jps@s390x.com> Co-authored-by: Jonathan Springer <jps@s390x.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds runtime plugin management capabilities to ContextForge — enabling global plugin enable/disable, per-plugin mode changes, and cross-worker/cross-pod state propagation via Redis pub/sub. Previously, plugin configuration was static (YAML loaded at startup) and changes required pod restarts.
The dynamic plugin configuration and per-tool bindings added in #4068 / #4143 built the data layer and single-worker runtime. This PR closes the multi-instance gaps by adding Redis-backed shared state, pub/sub-driven instant invalidation, TTL-based cache refresh as a safety net, and the missing API endpoints.
New endpoints:
PUT /admin/plugins— global enable/disable (writes to Redis, broadcasts via pub/sub)PUT /admin/plugins/{name}— per-plugin mode change (enforce/enforce_ignore_error/permissive/disabled)GET /admin/plugins— now includesplugins_globally_enabledfield from runtime stateBoth endpoints use Pydantic request/response models (
PluginToggleRequest,PluginModeUpdateResponse, etc.) for typed validation instead of raw dict parsing.Related issues: #4276 (gap analysis), #4230 (testing plan)
Gaps closed
HIGH — Multi-instance propagation and missing features
Gap 1 (HIGH) — Cache invalidation per-worker only:
reload_plugin_context()evicted cache in one Gunicorn worker only. Other workers in the same pod and all workers in other pods kept stale config indefinitely. Fixed with Redis pub/sub for instant cross-worker/cross-pod cache eviction, with TTL-based cache refresh (default 30s) as a safety net if pub/sub messages are missed.Gap 2 (HIGH) — Wildcard binding invalidation mismatch: Upsert of
tool_name="*"created context_id"team::*"but tools requested"team::my_tool". Eviction was a no-op — team-wide policies never propagated. Fixed withfactory.invalidate_team()that evicts ALL cached contexts for the team locally, plus ateam_binding_changepub/sub message that triggers the same eviction on all remote workers.Gap 3 (HIGH) — No cross-pod state propagation: Pods were completely isolated. A binding change on pod A was invisible to pods B and C forever. Fixed with Redis pub/sub channel
plugin:invalidationcarrying four typed message types (global_toggle,mode_change,binding_change,team_binding_change). The pub/sub listener is wired into the FastAPI lifespan with exponential backoff + jitter on reconnect.Gap 4 (HIGH) — No global plugin toggle in shared store:
_PLUGINS_ENABLEDwas a per-process Python global. Fixed withPUT /admin/pluginsthat writes to Redis (plugin:global:enabled). Workers check a 2-second in-memory cache (invalidated instantly by pub/sub) instead of hitting Redis per request.Gap 5 (HIGH) — No per-plugin mode change via API: Could not switch a plugin from
permissivetoenforceglobally at runtime. Fixed withPUT /admin/plugins/{name}that stores mode in Redis (plugin:{name}:mode, 24h TTL), broadcasts amode_changepub/sub message, and maintains an in-process_local_mode_overridesdict so single-node deployments work without Redis.Gap 6 (HIGH) —
GET /admin/pluginsdidn't reflect runtime state: Returned YAML boot-time config. Fixed by readingplugins_globally_enabledfrom Redis. The response now shows actual runtime state.Gap 7 (HIGH) — No TTL on plugin manager cache:
self._managersdict had no expiry — stale config was permanent until worker restart. Fixed with configurable TTL (default 30s) onTenantPluginManagerFactory. Managers older than TTL are automatically evicted and rebuilt from DB. Cache entries use a frozen_CachedManagerdataclass with an.is_expired()method.MEDIUM — Resilience and observability
Gap 8 (MEDIUM) — DB error kills manager build: If Postgres was briefly unavailable during
get_config_from_db(), the exception propagated and tool invocations failed. Fixed by re-raising the error so the build fails loudly — this prevents silently dropping per-team security plugin bindings (rate limiter, PII filter) that would otherwise fall back to the permissive YAML defaults. The caller retries on the next request or cache TTL expiry.Gap 9 (MEDIUM) —
_PLUGINS_ENABLEDnot checked per-request: Once a manager was cached, the global flag wasn't rechecked. Fixed by checking a 2-second in-memory cache of the shared toggle on everyget_plugin_manager()call. The cache is invalidated instantly by pub/sub, so the 2s TTL is only the worst-case bound when pub/sub is down.Gap 10 (MEDIUM) — Concurrent upserts race on reload: Mitigated via TTL — even if invalidation is missed due to a race, the stale manager is rebuilt within 30s.
Gap 11 (MEDIUM) —
__global__context never reloaded: Default context for HTTP middleware hooks was cached once. Now auto-refreshes via TTL, andinvalidate_all_plugin_managers()includes it in the eviction sweep.LOW — Documentation and observability
Gap 12 (LOW) — No audit trail for runtime changes: Added structured logging for global toggle and per-plugin mode changes (user, action, timestamp,
redis_persistedstatus).Gap 13 (LOW) — No multi-instance caveats in docs: Updated
plugin-bindings-api.md— cache invalidation is pub/sub-driven (instant) with TTL fallback (30s default), wildcard eviction behaviour documented.Gap 14 (LOW) — No logging for missing team/tool bindings: Added
logger.debugfor empty binding lookups andlogger.error(withexc_info) for DB errors.Architecture
Before: per-process plugin state (no shared state)
After: Redis-backed shared state with pub/sub + TTL fallback
Request flow: tool invocation with plugin checks
Key design decisions
Three-layer propagation — pub/sub for instant notification (~0ms), TTL cache expiry as fallback (30s), cached Redis key read as confirmation (2s cache for global toggle). Fastest layer wins. Industry-standard pattern used by Kong, Envoy, and Consul.
Pub/sub is notification, Redis keys are truth — pub/sub tells workers "something changed, evict your cache." Workers then read the actual new state from Redis keys (global toggle, per-plugin mode) or DB (bindings) on the next request. If a pub/sub message is lost, TTL ensures the cache is rebuilt within 30s anyway.
Dual-store fallback for mode overrides — Redis is authoritative when available. An in-process
_local_mode_overridesdict provides fallback for single-node/no-Redis deployments and write-through for the local worker. Redis-synced entries carry a TTL-aligned expiry (matching the Redis 24h key TTL) so both copies clear together. Local-only entries (Redis write failed) carry no expiry — the operator never got confirmation a timer started.Isolation modules for import-cycle prevention — the plugin
framework/package cannot import frommcpgateway.utils(enforced by a pre-commit hook). Two leaf modules break what would otherwise be circular dependencies:_redis.py— dependency-inversion shim for Redis access. The gateway registers its Redis client provider at lifespan startup viaset_shared_redis_provider(); framework modules callget_shared_redis_client()without naming the gateway util. When no provider is registered (tests, framework-only deployments), Redis-unavailable fallback paths engage automatically._state.py— shared mutable state (per-plugin mode overrides, degraded-boot flags). Breaks the__init__→manager.py→__init__import cycle:manager.pyneeds the local override map during rebuild, and importing it from__init__.pywould close the loop. Exposesset_local_mode_override()/prune_expired_local_overrides()/active_local_mode_overrides()helpers so all mutation flows through a single seam.Factory init unconditional — the plugin manager factory is initialized regardless of the local
plugins.enabledsetting. This allows a peer worker to runtime-enable plugins viaPUT /admin/pluginswithout requiring a restart of nodes that booted with plugins disabled. If init fails andplugins.enabled=true, the gateway crashes (loud failure). If init fails andplugins.enabled=false, the gateway boots with a warning — runtime-enable from a peer will require a restart.MGET batched reads — per-plugin mode overrides are fetched in a single Redis MGET call during manager rebuild, not N individual GETs. One round-trip regardless of plugin count.
Typed pub/sub messages — pub/sub frames are Pydantic
BaseModelsubclasses in a discriminated union (_GlobalToggleMsg,_ModeChangeMsg,_BindingChangeMsg,_TeamBindingChangeMsg). A typo in thetypefield on the producer fails Pydantic validation on the listener instead of silently no-oping.24h TTL on mode override keys — stale overrides auto-expire. Prevents forgotten overrides from persisting indefinitely across gateway restarts.
DB errors re-raise, not swallow —
get_config_from_db()re-raises on DB errors instead of returningNone. This prevents silently dropping per-team security plugin bindings (rate limiter, PII filter) that would otherwise fall back to the permissive YAML defaults. The caller retries on the next request or cache TTL expiry.Degraded-boot signal — when a node boots with
plugins.enabled=falseand the opportunistic factory init fails,mark_factory_init_degraded()is called. If a peer later flips the shared toggle to enabled,get_plugin_manager()emits one ERROR explaining that this node can't serve plugins and requires a restart. Prevents silent drift where a node appears healthy but silently ignores runtime-enable.Resilient invalidation on mode change —
update_plugin_modewraps the post-writeinvalidate_all_plugin_managers()in try/except. The override is already stored in Redis + the local map before invalidation runs; a cache-refresh glitch must not turn an already-applied change into a 500. On failure the operator sees a WARNING and the TTL refresh reaches the new mode within 30s.Mode pre-staging on disabled nodes —
PUT /admin/plugins/{name}validates plugin names againstfactory._base_config(the YAML config loaded at startup), not against the live manager. This means operators can pre-stage mode overrides on a node with plugins disabled — the override takes effect when the shared toggle is flipped on, without requiring a second API call.Additional improvements
Plugin bindings API docs — updated
docs/docs/api/plugin-bindings-api.mdwith multi-instance cache propagation caveats._invalidate_and_broadcast()helper — the wildcard-vs-specific invalidation split was copy-pasted across three binding handlers (upsert, delete-by-reference, delete-by-UUID). Extracted into a shared helper intool_plugin_bindings.pyso future changes land in one place.match/casedispatch —_handle_invalidation_messagereplaced fourisinstance(frame, X)branches with structural pattern matching on the discriminated union. Makes union closure explicit and drops redundant return statements.Test isolation — autouse fixture in
tests/unit/mcpgateway/conftest.pyresets the framework's shared Redis provider between tests. Prevents lifespan-exercising tests from leaking a mock provider into subsequent tests via module-level state.Test results
Unit tests — plugin runtime management (65 tests)
All tests use mocked Redis — no external dependencies. Runs in CI.
Unit tests — admin plugin runtime endpoints (22 tests)
Tests cover RBAC enforcement, Pydantic validation, Redis/no-Redis paths,
and invalidation failure resilience.
Integration tests — Redis plugin runtime (22 tests)
Tests use real Redis (auto-starts Docker container if not running).
Integration tests — docker-compose multi-replica (23 tests)
Tests run against a live gateway (docker-compose with 3 replicas + NGINX).
Limitations
Plugin config stored in Postgres, not Redis — full plugin config (rate limits, detection rules, etc.) is stored in Postgres and read on cache rebuild. For sub-second config propagation, a write-behind pattern (Redis-first with async Postgres replication) would be needed. Not required at current scale (4 plugins, infrequent config changes).
Redis required for cross-instance consistency — without Redis, the global toggle falls back to the per-process in-memory flag. Per-plugin mode overrides fall back to the
_local_mode_overridesdict (so mode changes work on the local worker, but don't propagate to other workers/pods). The system degrades to single-instance behavior rather than failing.DB errors fail the manager rebuild — a transient Postgres outage causes individual tool invocations to fail (no cached manager available) rather than silently falling back to YAML defaults. This is a deliberate trade-off: for security-critical plugins (rate limiter, PII filter), silently dropping per-team bindings is worse than a transient failure. The caller retries on the next request or cache TTL expiry.
No integration tests in CI — the 22 Redis integration tests and 23 docker-compose tests run locally. The 87 unit tests (65 + 22) run in CI. CI-compatible integration tests using TestClient + auto-started Redis are planned.
Future work
Write-behind caching for plugin config
The current architecture writes plugin config to Postgres first, then workers read it on cache rebuild. For use cases requiring sub-second config propagation at scale, a write-behind pattern could be adopted:
This is standard in high-throughput systems (Shopify uses it for cart/inventory, Discord for presence, AWS ElastiCache best practices recommend it). The benefit is that the API call returns in 0.1ms instead of ~5ms, and config changes propagate instantly via pub/sub.
Trade-off: Eventual consistency between Redis and Postgres — if Redis dies before the async Postgres write completes, the config update is lost. Mitigation: reconciliation on startup (compare Redis state vs Postgres and resolve differences).
Not needed for the current scale (4 plugins, infrequent config changes), but a natural evolution if ContextForge grows to handle thousands of per-tool config updates per minute.