diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c75b4173d..3731b0f92 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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 diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index fff9e7b71..4f02dc676 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -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.