Background
mcpgateway/services/tool_service.py:_build_rust_native_tool_post_invoke_retry_policy currently imports RetryConfig directly from the optional cpex-retry-with-backoff PyPI package:
try:
from cpex_retry_with_backoff import RetryConfig
except ImportError:
return (None, True)
This is a leaky abstraction: the gateway core knows about the internal config schema of an opt-in plugin. The import exists solely to translate the plugin's pydantic config into the wire format that tools_rust/mcp_runtime expects when taking the Rust fast path for tool post-invoke.
The current try/except ImportError guard is correct and safe (the import is only reached when the plugin is actively loaded, which means the cpex package must already be installed), but it bakes a cross-package dependency into gateway core that we'd rather not have long-term.
Why not move cpex-retry-with-backoff to a core dependency?
We considered this and decided against it. cpex-retry-with-backoff sits in the same architectural tier as the other five cpex-* plugins (PII filter, secrets detection, URL reputation, encoded exfil detection, rate limiter) — all are opt-in via the [plugins] extra. Promoting only one breaks the symmetry and forces operators who don't use retry behavior to install the package anyway. The whole point of migrating plugins to PyPI was to decouple their release cadence from the gateway core.
Proposed refactor
Have the plugin expose the Rust-native policy transformation itself, so the gateway never needs to import the plugin's internal types:
# In cpex_retry_with_backoff (upstream change — see linked issue):
class RetryWithBackoffPlugin(Plugin):
def to_rust_native_policy(self, tool_name: str, ceiling: int) -> Optional[dict]:
'''Return the Rust runtime wire format, or None if not eligible (e.g. check_text_content=True).'''
...
Then tool_service.py simplifies to:
def _build_rust_native_tool_post_invoke_retry_policy(self, plugin_manager, tool_name, hook_global_context):
# ... existing hook discovery / filtering logic unchanged ...
if len(active_hook_refs) != 1 or active_hook_refs[0].plugin_ref.name != "RetryWithBackoffPlugin":
return (None, True)
retry_hook = active_hook_refs[0]
policy = retry_hook.plugin_ref.plugin.to_rust_native_policy(tool_name, settings.max_tool_retries)
return (policy, policy is None)
Benefits:
- No
import cpex_retry_with_backoff anywhere in gateway core
- No
try/except ImportError defensive guard
- Plugin owns its own config schema and wire-format translation (single source of truth)
- Future post-invoke plugins can implement the same
to_rust_native_policy() protocol without touching tool_service.py at all
Dependency
This refactor requires an upstream change in the cpex-retry-with-backoff package. Tracking issue:
Once that ships and is published to PyPI, bump the minimum version in pyproject.toml [plugins] extra and apply the gateway-side refactor in this issue.
Acceptance criteria
Context
Surfaced during PR review of #3965 (in-tree plugin → PyPI migration). The lazy-import-with-fallback was the right tactical fix for that PR; this issue tracks the architectural cleanup as a follow-up.
Background
mcpgateway/services/tool_service.py:_build_rust_native_tool_post_invoke_retry_policycurrently importsRetryConfigdirectly from the optionalcpex-retry-with-backoffPyPI package:This is a leaky abstraction: the gateway core knows about the internal config schema of an opt-in plugin. The import exists solely to translate the plugin's pydantic config into the wire format that
tools_rust/mcp_runtimeexpects when taking the Rust fast path for tool post-invoke.The current
try/except ImportErrorguard is correct and safe (the import is only reached when the plugin is actively loaded, which means the cpex package must already be installed), but it bakes a cross-package dependency into gateway core that we'd rather not have long-term.Why not move
cpex-retry-with-backoffto a core dependency?We considered this and decided against it.
cpex-retry-with-backoffsits in the same architectural tier as the other fivecpex-*plugins (PII filter, secrets detection, URL reputation, encoded exfil detection, rate limiter) — all are opt-in via the[plugins]extra. Promoting only one breaks the symmetry and forces operators who don't use retry behavior to install the package anyway. The whole point of migrating plugins to PyPI was to decouple their release cadence from the gateway core.Proposed refactor
Have the plugin expose the Rust-native policy transformation itself, so the gateway never needs to import the plugin's internal types:
Then
tool_service.pysimplifies to:Benefits:
import cpex_retry_with_backoffanywhere in gateway coretry/except ImportErrordefensive guardto_rust_native_policy()protocol without touchingtool_service.pyat allDependency
This refactor requires an upstream change in the
cpex-retry-with-backoffpackage. Tracking issue:Once that ships and is published to PyPI, bump the minimum version in
pyproject.toml[plugins]extra and apply the gateway-side refactor in this issue.Acceptance criteria
RetryWithBackoffPlugin.to_rust_native_policy()is available in a publishedcpex-retry-with-backoffrelease (see linked cpex-plugins issue).pyproject.toml[plugins]extra requires the new minimum version.mcpgateway/services/tool_service.pyno longer importscpex_retry_with_backoff(verified by grep).Context
Surfaced during PR review of #3965 (in-tree plugin → PyPI migration). The lazy-import-with-fallback was the right tactical fix for that PR; this issue tracks the architectural cleanup as a follow-up.