mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 14:04:18 +02:00
Reject plugin registration after the setup pass completes (#3973)
This commit is contained in:
parent
54e83367a3
commit
769e998017
2 changed files with 66 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue