Reject plugin registration after the setup pass completes (#3973)

This commit is contained in:
Jeremiah Lowin 2026-04-18 20:22:27 -04:00 committed by GitHub
commit 769e998017
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 66 additions and 13 deletions

View file

@ -513,9 +513,9 @@ class FastMCP(
Appends the plugin to the server's ordered plugin list and
synchronously collects its HTTP routes (see below). Middleware,
transforms, and providers are collected later, during the server's
startup sequence, because those hooks may reference state the
plugin populates during `setup()`.
transforms, and providers are collected later, during the
server's startup sequence, because those hooks may reference
state the plugin populates while its `run()` context is active.
HTTP routes are collected eagerly because HTTP transports snapshot
the server's route list when they construct the Starlette app —
@ -526,25 +526,37 @@ class FastMCP(
working for HTTP transports.
Loader caveat: plugins added from inside another plugin's
`setup()` (the loader pattern) can still contribute middleware,
`run()` (the loader pattern) can still contribute middleware,
transforms, and providers, but their routes may not be reachable
over HTTP/SSE transports those transports' route lists are
already fixed by the time `setup()` runs. Loaders that need to
already fixed by the time `run()` enters. Loaders that need to
contribute routes should use the stdio transport or expose the
routes via a non-loader plugin registered at construction time.
Raises:
PluginError: If called after the server has started, or if the
plugin's `fastmcp_version` compatibility check fails.
Args:
plugin: A :class:`Plugin` instance. Plugins are registered in
the order they are added; middleware is a stack.
Raises:
PluginError: If called after the server's plugin-entry pass
has completed (except from inside a loader plugin's
`run()`), or if the plugin's `fastmcp_version`
compatibility check fails.
"""
if self._started.is_set():
# Reject registration once the lifespan is active and we're past
# the plugin-entry pass. The loader-pattern exception is the only
# case where add_plugin() runs during a live lifespan, and it's
# gated by `_in_plugin_setup_pass`. Checking `_started` alone is
# too narrow: `_started` is only set after provider lifespans
# enter and is cleared before teardown, leaving windows in which
# add_plugin() would silently register a plugin whose `run()`
# never enters for the current cycle.
if self._lifespan_result_set and not self._in_plugin_setup_pass:
raise PluginError(
f"Cannot add plugin {plugin.meta.name!r}: the server has "
"already started. Register plugins before the server binds."
f"Cannot add plugin {plugin.meta.name!r}: the server's "
"plugin-entry pass has already completed. Register "
"plugins before the server starts, or from inside "
"another plugin's `run()` (the loader pattern)."
)
plugin.check_fastmcp_compatibility()
# Compute routes up front so a failure inside plugin.routes() does

View file

@ -337,9 +337,50 @@ class TestLifecycle:
mcp = FastMCP("t")
async with Client(mcp) as c:
await c.ping()
with pytest.raises(PluginError, match="already started"):
with pytest.raises(PluginError, match="plugin-entry pass"):
mcp.add_plugin(P())
async def test_add_plugin_raises_when_called_from_provider_lifespan(self):
"""Post-setup-pass registration must be rejected, not silently allowed.
`_started` is set only after provider lifespans enter, so a
provider's `lifespan()` callback runs with `_started` False but
the plugin-entry pass already complete. Registering a plugin in
that window would skip `run()` and contribution collection for
the current cycle and leave the plugin in `self.plugins`; the
guard must reject it.
"""
from contextlib import asynccontextmanager
from fastmcp.server.providers import Provider
class PluginInProviderLifespan(Provider):
def __init__(self, server):
super().__init__()
self.server = server
self.raised: Exception | None = None
@asynccontextmanager
async def lifespan(self):
class Late(Plugin):
meta = PluginMeta(name="late", version="0.1.0")
try:
self.server.add_plugin(Late())
except Exception as exc:
self.raised = exc
yield
mcp = FastMCP("t")
provider = PluginInProviderLifespan(mcp)
mcp.add_provider(provider)
async with Client(mcp) as c:
await c.ping()
assert isinstance(provider.raised, PluginError)
assert "plugin-entry pass" in str(provider.raised)
async def test_duplicate_registration_tears_down_once(self):
"""Registering the same instance twice must only call teardown() once.