diff --git a/src/fastmcp/server/mixins/lifespan.py b/src/fastmcp/server/mixins/lifespan.py index 951a2bbc6..d3ad127b0 100644 --- a/src/fastmcp/server/mixins/lifespan.py +++ b/src/fastmcp/server/mixins/lifespan.py @@ -171,13 +171,14 @@ class LifespanMixin: self._lifespan_result = user_lifespan_result self._lifespan_result_set = True - # Plugin entry pass: each registered plugin's `run()` async - # context manager wraps the server's lifespan. Runs before - # provider lifespans and `_started` because plugins may - # contribute providers. Partial-failure safety is automatic - # — AsyncExitStack only unwinds plugin contexts that were - # successfully entered, so a raising plugin doesn't tear - # down plugins that never entered. + # Plugin runtime pass: each registered plugin's `run()` async + # context manager wraps the server's lifespan. Contributions + # were already installed at add_plugin() time, so this only + # enters async runtime work before provider lifespans and + # `_started`. Partial-failure safety is automatic — + # AsyncExitStack only unwinds plugin contexts that were + # successfully entered, so a raising plugin doesn't tear down + # plugins that never entered. await self._enter_plugin_contexts(stack) # Start lifespans for all providers diff --git a/src/fastmcp/server/mixins/transport.py b/src/fastmcp/server/mixins/transport.py index 3c87a23e2..b28f7e7d9 100644 --- a/src/fastmcp/server/mixins/transport.py +++ b/src/fastmcp/server/mixins/transport.py @@ -333,7 +333,6 @@ class TransportMixin: Returns: A Starlette application configured with the specified transport """ - if transport in ("streamable-http", "http"): return create_streamable_http_app( server=self, diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py index e6bb9762c..8851e22b1 100644 --- a/src/fastmcp/server/plugins/base.py +++ b/src/fastmcp/server/plugins/base.py @@ -38,6 +38,7 @@ from typing_extensions import Self import fastmcp from fastmcp.exceptions import FastMCPError +from fastmcp.server.auth.auth import AuthProvider from fastmcp.server.middleware import Middleware from fastmcp.server.providers import Provider from fastmcp.server.transforms import Transform @@ -426,12 +427,12 @@ class Plugin(Generic[C]): # trivially. cls._validate_config_cls(cls._config_cls) - # Framework-internal marker. Set to True by `FastMCP.add_plugin` when - # the plugin is added from inside another plugin's setup() (the loader - # pattern). The server removes ephemeral plugins and their - # contributions on teardown so loaders don't accumulate duplicates - # across lifespan cycles. - _fastmcp_ephemeral: bool = False + # Framework-internal: the server this plugin is attached to, or None + # if not yet installed. Set by `install()` and checked to enforce the + # "one plugin instance per server" contract — plugin instances are + # single-server by design so plugin authors don't have to reason about + # per-server state isolation for contributions and lifecycle hooks. + _installed_on: FastMCP | None = None def __init__(self, config: C | dict[str, Any] | None = None) -> None: meta = getattr(type(self), "meta", None) @@ -615,23 +616,58 @@ class Plugin(Generic[C]): # -- lifecycle ------------------------------------------------------------ + def on_install(self, server: FastMCP) -> None: + """Optional sync hook run when the plugin is attached to a server. + + The framework calls this exactly once, from inside + `FastMCP.add_plugin()`, after the plugin has been recorded in the + server's plugin list but before its contribution hooks are + pulled. Default: no-op. + + Override to do server-aware synchronous setup — stash a server + reference, compute derived state that contribution hooks depend + on, or recursively register child plugins (the "loader" pattern): + + ```python + class ConfigLoader(Plugin[LoaderConfig]): + def on_install(self, server): + for spec in self.config.children: + server.add_plugin(build_plugin(spec)) + ``` + + Child plugins registered from `on_install` appear after this + plugin in `server.plugins`, preserving parent-before-child + registration order. + + Async setup belongs in `run()` / `setup()`, not here. `on_install` + is synchronous because it runs inside `FastMCP.__init__` before + any event loop exists. + """ + @asynccontextmanager async def run(self, server: FastMCP) -> AsyncIterator[None]: - """Async context manager wrapping the plugin's lifetime. + """Async context manager wrapping the plugin's runtime lifetime. + + Used for **async work only** — the plugin's contributions are + already installed by the time `run()` is entered. Opening database + connections, starting background tasks, hydrating an + already-installed provider with live state: all fine. Registering + additional plugins, middleware, or providers here is discouraged: + use `on_install()` for plugin composition so the server graph is + configured before runtime work begins. The framework enters `async with plugin.run(server):` on the - server's lifespan stack. Everything before the `yield` runs - during startup (in plugin registration order); the `yield` spans - the server's active lifetime; everything after the `yield` runs - on shutdown (in reverse registration order). Cancellation on - shutdown unwinds the context manager automatically. + server's lifespan stack once per lifespan cycle. Everything before + the `yield` runs during startup (in registration order); the + `yield` spans the server's active lifetime; everything after + runs on shutdown (reverse order). The default implementation calls `setup(server)` before the `yield` and `teardown()` after it, so plugins that just need - one-shot init/cleanup can keep overriding just those two - methods. Long-running plugins (channels, integration bridges, - background workers) override `run()` directly to use - `async with` for resource management and task groups: + one-shot init/cleanup can keep overriding just those two methods. + Long-running plugins (channels, integration bridges, background + workers) override `run()` directly to use `async with` for + resource management and task groups: @asynccontextmanager async def run(self, server): @@ -656,10 +692,14 @@ class Plugin(Generic[C]): """One-shot async initialization. Called by the default `run()` before the `yield`. - Override for simple init work — compile regexes, warm caches, - open connections, register additional plugins from a loader. For - anything involving long-lived resources or background tasks, - override `run()` directly instead and use `async with`. + Override for simple async init work — open connections, warm + caches, hydrate an already-installed provider. For anything + involving long-lived resources or background tasks, override + `run()` directly instead and use `async with`. + + Prefer `on_install()` for registering additional plugins or + contributions so plugin composition happens at install time, before + async runtime work begins. """ async def teardown(self) -> None: @@ -685,6 +725,34 @@ class Plugin(Generic[C]): """Return component providers.""" return [] + def auth(self) -> AuthProvider | None: + """Return the auth provider this plugin contributes, or `None`. + + Any `AuthProvider` subclass is accepted — a `TokenVerifier`, a + full OAuth server (`OAuthProvider` / `RemoteAuthProvider` / + `OAuthProxy`), or a pre-composed `MultiAuth`. + + FastMCP's auth slot is **singular**. Across the user-declared + `auth=` and every plugin's `auth()` return, at most one + `AuthProvider` may be active. Multiple contributors raise + `PluginError` with an error that names every source so the + operator can resolve the conflict explicitly. + + **Best practice for plugins that contribute auth**: expose a + config knob (conventionally `enable_auth: bool = True`) so users + who want the plugin's other features but prefer different auth + can disable it without framework-level composition rules. Return + `None` when the knob is off. + + For genuine multi-source auth, users construct a `MultiAuth` + explicitly and pass it as the single `auth=` arg — the framework + never auto-composes, because silent composition produces + surprising behavior at token-verification time. + + The default returns `None`. + """ + return None + def capabilities(self) -> dict[str, Any]: """Return a partial `ServerCapabilities` dict to merge into the server's capabilities. diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 75e0a258e..33c2347c1 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -391,6 +391,14 @@ class FastMCP( ) self.auth: AuthProvider | None = auth + # Identifies where `self.auth` came from, for a clear error if a + # plugin later contributes a second auth provider. `None` while + # `self.auth` is `None`; set to `"user-declared auth="` when the + # caller passes `auth=...` and to `"plugin ''"` when a + # plugin contributes one. + self._auth_source: str | None = ( + "user-declared auth=" if auth is not None else None + ) if tools: for tool in tools: @@ -420,28 +428,11 @@ class FastMCP( self.middleware.append(DereferenceRefsMiddleware()) # Plugin registry: an ordered list, populated by `add_plugin()` and - # `plugins=[...]`. Each plugin's `run()` async context manager wraps - # the server's lifespan (see `_enter_plugin_contexts`). + # `plugins=[...]`. Plugins install their contributions synchronously + # at `add_plugin()` time; each plugin's `run()` async context manager + # wraps the server's lifespan (see `_enter_plugin_contexts`). self.plugins: list[Plugin] = [] - # Plugins whose contributions (middleware/transforms/providers/routes) - # have been collected onto the server. Server contributions persist - # across lifespan cycles like server-authored middleware, so we - # don't re-install them on re-entry. - self._plugins_contributed: set[int] = set() - # Plugins whose `run()` context has been entered in the current - # lifespan cycle. Used to dedupe entry when the same instance is - # registered twice; reset after the cycle's ephemeral cleanup. - self._plugins_entered: set[int] = set() - # Per-plugin record of contributions we installed, stored as - # (container, item) tuples so we can reverse them when an - # ephemeral plugin is torn down. - self._plugin_contributions: dict[int, list[tuple[list[Any], Any]]] = {} - # True while the plugin-entry loop is running. `add_plugin()` uses - # this to flag ephemeral plugins — plugins added from inside - # another plugin's `run()` (the loader pattern). Ephemeral plugins - # are removed after all plugin contexts exit so loaders can - # freshly re-hydrate children on the next lifespan cycle. - self._in_plugin_setup_pass: bool = False + self._plugin_capabilities: dict[int, dict[str, Any]] = {} for p in plugins or []: self.add_plugin(p) @@ -512,182 +503,108 @@ class FastMCP( def add_plugin(self, plugin: Plugin) -> None: """Register a plugin with this server. - 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 while its `run()` context is active. + Collects all of the plugin's contributions — providers, middleware, + transforms, routes, auth, capabilities — synchronously, once, at + registration time. Plugin lifecycle (`run()` / `setup()` / + `teardown()`) is strictly for async runtime work. - HTTP routes are collected eagerly because HTTP transports snapshot - the server's route list when they construct the Starlette app — - which happens before the lifespan runs. A route returned by - `plugin.routes()` after the app is built would sit in - `_additional_http_routes` but never be mounted and would always - 404. Collecting at registration time keeps non-loader plugins - working for HTTP transports. + The plugin's `on_install(server)` hook runs first, which enforces the + "one plugin instance per server" contract (plugins are single- + server by design — construct a fresh instance for each server + rather than sharing). After `on_install()` returns, contribution + hooks are called and their results are applied. If any step + raises, registration fails loudly; the caller should discard the + partially configured server rather than expect best-effort + recovery. - Loader caveat: plugins added from inside another plugin's - `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 `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. + Dynamic plugin loading (the "loader" pattern) is supported via + `Plugin.on_install(server)`, which may call `server.add_plugin()` + recursively. That path runs entirely at registration time, so + the resulting plugin tree is installed before runtime work starts. Args: - plugin: A :class:`Plugin` instance. Plugins are registered in - the order they are added; middleware is a stack. + plugin: A :class:`Plugin` instance. 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. + PluginError: If the plugin is already installed on a server, + if the plugin's `fastmcp_version` compatibility check + fails, or if another source has already contributed auth. """ - # 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'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 - # not leave a half-registered plugin in self.plugins. - routes = list(plugin.routes()) + if plugin._installed_on is not None: + raise PluginError( + f"Plugin {plugin.meta.name!r} is already installed on " + f"{plugin._installed_on.name!r}. Plugin instances are " + "single-server by design — construct a fresh instance " + "per server rather than sharing one across servers." + ) + + # Attach and append FIRST so any children registered recursively + # by `on_install` land after this plugin in `self.plugins`, + # preserving parent-before-child registration order in the plugin + # list. Child contribution side effects still occur when the + # child is added from on_install. + plugin._installed_on = self self.plugins.append(plugin) - # Flag loader-added plugins as ephemeral so teardown can remove - # them along with their contributions. Written unconditionally so - # re-registering an instance that was previously marked ephemeral - # (added inside a setup pass and then cleaned up) as a permanent - # plugin clears the stale marker rather than inheriting it. - plugin._fastmcp_ephemeral = self._in_plugin_setup_pass - records = self._plugin_contributions.setdefault(id(plugin), []) - for route in routes: + + plugin.on_install(self) + + # Gather everything up front: any hook that raises must not have + # mutated server state. Auth is the only hook that can conflict + # with prior server state (the singular-auth-slot rule), so we + # validate it before committing anything else. + contributed_auth = plugin.auth() + contributed_capabilities = plugin.capabilities() + contributed_mws = list(plugin.middleware()) + contributed_transforms = list(plugin.transforms()) + contributed_providers = list(plugin.providers()) + contributed_routes = list(plugin.routes()) + + if contributed_auth is not None and self.auth is not None: + prior = self._auth_source or "user-declared auth=" + raise PluginError( + f"Multiple auth sources declared: {prior}, " + f"plugin {plugin.meta.name!r}. FastMCP accepts a " + "single auth provider. Disable auth on all but one " + "source (typically via the plugin's config), or " + "construct a `MultiAuth` explicitly in Python and " + "pass it as the single `auth=` arg." + ) + + # Commit. From here we do not raise. + self._plugin_capabilities[id(plugin)] = contributed_capabilities + for mw in contributed_mws: + self.add_middleware(mw) + for transform in contributed_transforms: + self.add_transform(transform) + for provider in contributed_providers: + self.add_provider(provider) + for route in contributed_routes: self._additional_http_routes.append(route) - records.append((self._additional_http_routes, route)) + if contributed_auth is not None: + self.auth = contributed_auth + self._auth_source = f"plugin {plugin.meta.name!r}" async def _enter_plugin_contexts(self, stack: AsyncExitStack) -> None: """Enter each registered plugin's `run()` context on the given stack. - Called during server startup (from `_lifespan_manager`), before - the server binds. Iterates the plugin list in order, entering - `async with plugin.run(server):` on the shared exit stack for - each plugin. Plugins added during another plugin's `run()` (the - loader pattern) are picked up by the same loop because the - iteration advances against a live index. + Called once per server lifespan. Contributions were collected when + each plugin was registered via `add_plugin()`. All this loop does + is wrap each plugin's async runtime lifetime around the server's + lifespan. - Contributions (middleware, transforms, providers) are collected - once per plugin across all lifespan cycles — once installed, they - persist on the server just like server-authored middleware. - Routes were already collected synchronously at `add_plugin()` - time so HTTP transports see them before the lifespan runs. - - Ephemeral cleanup is registered as a post-stack callback so it - runs after every plugin's `run()` has exited but before the - outer server lifespan unwinds further. + Order: registration order on entry, reverse order on exit (the + exit stack handles the reversal automatically). Exceptions inside + a plugin's `run()` body unwind already-entered contexts cleanly. """ - # Register ephemeral cleanup FIRST so it unwinds LAST (after all - # plugin.run() contexts have exited). - stack.push_async_callback(self._cleanup_ephemeral_plugins) - - # Plugin-entry loop: mutating-list iteration. New plugins appended - # by a plugin's run() (loader pattern) are picked up on subsequent - # iterations. `_in_plugin_setup_pass` lets add_plugin() mark those - # as ephemeral (see add_plugin). `_plugins_entered` dedupes: a - # plugin instance registered twice only enters its run() once per - # lifespan cycle, keeping setup/teardown counts symmetric. - self._in_plugin_setup_pass = True - try: - i = 0 - while i < len(self.plugins): - plugin = self.plugins[i] - i += 1 - if id(plugin) in self._plugins_entered: - continue - self._plugins_entered.add(id(plugin)) - try: - await stack.enter_async_context(plugin.run(self)) - except Exception: - logger.exception( - "Plugin %r raised while entering run()", plugin.meta.name - ) - raise - finally: - self._in_plugin_setup_pass = False - - # Contribution collection: run in registration order. Guarded - # per-plugin because contributions persist across lifespan cycles; - # new plugins (e.g. added by a loader during this cycle's run) - # still get their contributions collected. Routes are collected - # synchronously at add_plugin() time (see above). for plugin in self.plugins: - if id(plugin) in self._plugins_contributed: - continue - # Gather everything first: any hook that raises aborts before - # any server state is mutated, so a retry on the next lifespan - # cycle starts clean rather than appending duplicate middleware - # on top of partial contributions. - mws = list(plugin.middleware()) - transforms_ = list(plugin.transforms()) - providers_ = list(plugin.providers()) - - records = self._plugin_contributions.setdefault(id(plugin), []) - for mw in mws: - self.add_middleware(mw) - records.append((self.middleware, mw)) - for transform in transforms_: - self.add_transform(transform) - records.append((self._transforms, transform)) - for provider in providers_: - # add_provider may wrap the value (for example a FastMCP - # is wrapped in FastMCPProvider). Record whatever - # actually landed in self.providers so teardown can find - # it by identity. - before = len(self.providers) - self.add_provider(provider) - for stored in self.providers[before:]: - records.append((self.providers, stored)) - self._plugins_contributed.add(id(plugin)) - - async def _cleanup_ephemeral_plugins(self) -> None: - """Remove ephemeral (loader-added) plugins after all contexts have exited. - - Runs as an `AsyncExitStack` callback registered in - `_enter_plugin_contexts` so it fires after every plugin's `run()` - has unwound. Without this, each lifespan cycle would accumulate a - fresh generation of loader-added children in `self.plugins`, and - their contributions would accumulate in the server's - middleware/transform/provider lists. - """ - # Reset the per-cycle entered set for the next cycle. - self._plugins_entered = set() - - ephemeral = [p for p in self.plugins if getattr(p, "_fastmcp_ephemeral", False)] - for plugin in ephemeral: - records = self._plugin_contributions.pop(id(plugin), []) - for container, item in reversed(records): - # Remove by identity rather than equality so a permanent - # contribution that happens to compare equal to `item` - # (e.g. a dataclass-style middleware with value-based - # `__eq__`) is not accidentally stripped. list.remove() - # uses `==`, which is the wrong matcher here. - for i, entry in enumerate(container): - if entry is item: - del container[i] - break - self._plugins_contributed.discard(id(plugin)) - self.plugins = [ - p for p in self.plugins if not getattr(p, "_fastmcp_ephemeral", False) - ] + try: + await stack.enter_async_context(plugin.run(self)) + except Exception: + logger.exception( + "Plugin %r raised while entering run()", plugin.meta.name + ) + raise def _apply_plugin_capabilities( self, capabilities: mcp.types.ServerCapabilities @@ -702,7 +619,9 @@ class FastMCP( update, applied recursively. Plugins that return an empty dict contribute nothing. """ - contributions = [plugin.capabilities() for plugin in self.plugins] + contributions = [ + self._plugin_capabilities.get(id(plugin), {}) for plugin in self.plugins + ] if not any(contributions): return capabilities diff --git a/tests/server/plugins/test_auth_hook.py b/tests/server/plugins/test_auth_hook.py new file mode 100644 index 000000000..3400b9b60 --- /dev/null +++ b/tests/server/plugins/test_auth_hook.py @@ -0,0 +1,248 @@ +"""Tests for the `Plugin.auth()` contribution hook (FMCP-24). + +Semantic rule: FastMCP's auth slot is singular. `auth=` + every plugin's +`auth()` return are collected; at most one `AuthProvider` may be active. +Multiple sources raise `PluginError` — no automatic `MultiAuth` wrapping. +Users who want multi-source auth build `MultiAuth` explicitly. +""" + +from __future__ import annotations + +import pytest + +from fastmcp import FastMCP +from fastmcp.server.auth.auth import AuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import StaticTokenVerifier +from fastmcp.server.plugins.base import Plugin, PluginError, PluginMeta + + +def _verifier(token: str = "t") -> TokenVerifier: + return StaticTokenVerifier(tokens={token: {"client_id": "c", "scopes": []}}) + + +class _FakeServerAuth(AuthProvider): + """Minimal non-TokenVerifier AuthProvider — stands in for an OAuth + server in tests without needing a real issuer URL.""" + + def __init__(self, base_url: str = "https://example.com") -> None: + super().__init__(base_url=base_url) + + async def verify_token(self, token): # type: ignore[override] + return None + + +class TestDefaultHook: + def test_plugin_auth_defaults_to_none(self): + class P(Plugin): + meta = PluginMeta(name="p") + + assert P().auth() is None + + +class TestSingleSource: + def test_lone_plugin_contribution_becomes_self_auth(self): + """One plugin contributing one AuthProvider, no user `auth=` → that + provider is installed directly as `self.auth`. No wrapping, no + lifespan round-trip: `self.auth` is set synchronously at + `add_plugin` time so HTTP/SSE transports see it when they build + the Starlette app.""" + v = _verifier() + + class P(Plugin): + meta = PluginMeta(name="p") + + def auth(self) -> AuthProvider | None: + return v + + mcp = FastMCP("t", plugins=[P()]) + assert mcp.auth is v + + def test_user_declared_alone_untouched(self): + """No plugin contributing auth → `self.auth` is exactly the user + value, no processing.""" + user_v = _verifier() + mcp = FastMCP("t", auth=user_v) + assert mcp.auth is user_v + + def test_no_sources_leaves_auth_none(self): + mcp = FastMCP("t") + assert mcp.auth is None + + def test_add_plugin_installs_auth(self): + """Plugin added after construction installs its auth synchronously.""" + v = _verifier() + mcp = FastMCP("t") + assert mcp.auth is None + + class P(Plugin): + meta = PluginMeta(name="p") + + def auth(self) -> AuthProvider | None: + return v + + mcp.add_plugin(P()) + assert mcp.auth is v + + +class TestMultipleSourcesRejected: + """FastMCP's auth slot is singular. Multiple contributors raise.""" + + def test_two_plugin_verifiers_raises(self): + v1, v2 = _verifier("one"), _verifier("two") + + class P1(Plugin): + meta = PluginMeta(name="p1") + + def auth(self) -> AuthProvider | None: + return v1 + + class P2(Plugin): + meta = PluginMeta(name="p2") + + def auth(self) -> AuthProvider | None: + return v2 + + with pytest.raises(PluginError, match="Multiple auth sources"): + FastMCP("t", plugins=[P1(), P2()]) + + def test_user_plus_plugin_raises(self): + """User-declared `auth=` + any plugin contribution is ambiguous — + framework doesn't silently pick a winner.""" + user_v, plugin_v = _verifier("u"), _verifier("p") + + class P(Plugin): + meta = PluginMeta(name="p") + + def auth(self) -> AuthProvider | None: + return plugin_v + + with pytest.raises(PluginError, match="Multiple auth sources"): + FastMCP("t", auth=user_v, plugins=[P()]) + + def test_two_server_contributions_raises(self): + """Also covers the server-server case (historical multiauth reason).""" + s1 = _FakeServerAuth("https://a.example") + s2 = _FakeServerAuth("https://b.example") + + class P1(Plugin): + meta = PluginMeta(name="p1") + + def auth(self) -> AuthProvider | None: + return s1 + + class P2(Plugin): + meta = PluginMeta(name="p2") + + def auth(self) -> AuthProvider | None: + return s2 + + with pytest.raises(PluginError, match="Multiple auth sources"): + FastMCP("t", plugins=[P1(), P2()]) + + def test_error_names_conflicting_sources(self): + """Operator needs to know which sources conflict so they can + disable auth on all but one.""" + v1, v2 = _verifier("a"), _verifier("b") + + class Alpha(Plugin): + meta = PluginMeta(name="alpha") + + def auth(self) -> AuthProvider | None: + return v1 + + class Beta(Plugin): + meta = PluginMeta(name="beta") + + def auth(self) -> AuthProvider | None: + return v2 + + with pytest.raises(PluginError) as exc_info: + FastMCP("t", plugins=[Alpha(), Beta()]) + + msg = str(exc_info.value) + # The "prior" source (alpha) and the rejected plugin (beta) must + # both appear so operators can act on the conflict without + # re-running with extra logging. + assert "'alpha'" in msg + assert "'beta'" in msg + + +class TestAddPluginFailures: + def test_rejected_auth_conflict_raises_loudly(self): + """A plugin whose auth contribution conflicts raises immediately. + + Plugin installation is not transactional: after a failed install, + callers should discard the partially configured server rather than + expect FastMCP to recover arbitrary plugin mutations. + """ + v1 = _verifier("one") + + class P1(Plugin): + meta = PluginMeta(name="p1") + + def auth(self) -> AuthProvider | None: + return v1 + + class P2(Plugin): + meta = PluginMeta(name="p2") + + def __init__(self) -> None: + super().__init__() + self._v = _verifier("two") + + def auth(self) -> AuthProvider | None: + return self._v + + p1 = P1() + mcp = FastMCP("t", plugins=[p1]) + assert mcp.plugins == [p1] + assert mcp.auth is v1 + + p2 = P2() + with pytest.raises(PluginError, match="Multiple auth sources"): + mcp.add_plugin(p2) + + assert mcp.plugins == [p1, p2] + assert mcp.auth is v1 + assert p2._installed_on is mcp + + +class TestSingleServerPerInstance: + def test_same_instance_registered_twice_raises(self): + """A plugin instance belongs to one server — registering the same + instance twice (same server or different) raises PluginError.""" + v = _verifier() + + class P(Plugin): + meta = PluginMeta(name="p") + + def auth(self) -> AuthProvider | None: + return v + + p = P() + mcp = FastMCP("t", plugins=[p]) + assert mcp.auth is v + + with pytest.raises(PluginError, match="already installed"): + mcp.add_plugin(p) + + def test_plugins_kwarg_duplicate_instance_raises(self): + """Duplicate instance in the `plugins=` kwarg is caught the same way.""" + + class P(Plugin): + meta = PluginMeta(name="p") + + p = P() + with pytest.raises(PluginError, match="already installed"): + FastMCP("t", plugins=[p, p]) + + def test_instance_on_second_server_raises(self): + """Sharing a plugin instance across two servers is not supported.""" + + class P(Plugin): + meta = PluginMeta(name="p") + + p = P() + FastMCP("a", plugins=[p]) + with pytest.raises(PluginError, match="already installed"): + FastMCP("b", plugins=[p]) diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index 7687cc372..7ffdb17a9 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import json -from contextlib import suppress +from contextlib import asynccontextmanager, suppress from importlib import metadata as importlib_metadata from importlib.metadata import version as dist_version from pathlib import Path @@ -919,125 +919,106 @@ class TestLifecycle: ("teardown", "a"), ] - async def test_loader_pattern_adds_plugins_during_setup(self): - """A plugin's setup() can call server.add_plugin() and the setup pass sees it. - - Mid-cycle the loader-added children are present; after teardown - they're removed (ephemeral cleanup), so the loader can freshly - re-hydrate them on the next cycle. - """ + async def test_loader_pattern_adds_plugins_from_on_install(self): + """A plugin's `on_install(server)` can call `server.add_plugin()` + to register child plugins. The loader runs at registration time, + so child plugins are installed before any lifespan starts.""" recorder = _Recorder() - class Child(Plugin): - meta = PluginMeta(name="child", version="0.1.0") + class ChildA(Plugin): + meta = PluginMeta(name="child-a", version="0.1.0") async def setup(self, server): - recorder.events.append(("setup", "child")) + recorder.events.append(("setup", "child-a")) + + class ChildB(Plugin): + meta = PluginMeta(name="child-b", version="0.1.0") + + async def setup(self, server): + recorder.events.append(("setup", "child-b")) class Loader(Plugin): meta = PluginMeta(name="loader", version="0.1.0") - async def setup(self, server): - recorder.events.append(("setup", "loader")) - server.add_plugin(Child()) - server.add_plugin(Child()) + def on_install(self, server): + server.add_plugin(ChildA()) + server.add_plugin(ChildB()) mcp = FastMCP("t", plugins=[Loader()]) - async with Client(mcp) as c: - await c.ping() - # Mid-cycle, the loader's children are registered. - assert [p.meta.name for p in mcp.plugins] == [ - "loader", - "child", - "child", - ] - - assert recorder.events == [ - ("setup", "loader"), - ("setup", "child"), - ("setup", "child"), + # Children are registered at construction; no lifespan needed. + assert [p.meta.name for p in mcp.plugins] == [ + "loader", + "child-a", + "child-b", ] - # After teardown, ephemeral children have been removed. - assert [p.meta.name for p in mcp.plugins] == ["loader"] - - async def test_add_plugin_after_startup_raises(self): - class P(Plugin): - meta = PluginMeta(name="p", version="0.1.0") - - mcp = FastMCP("t") - async with Client(mcp) as c: - await c.ping() - 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) + # Children stay registered across the lifespan — they're permanent + # (registered during construction), not ephemeral. + assert [p.meta.name for p in mcp.plugins] == [ + "loader", + "child-a", + "child-b", + ] + assert ("setup", "child-a") in recorder.events + assert ("setup", "child-b") in recorder.events - async def test_duplicate_registration_tears_down_once(self): - """Registering the same instance twice must only call teardown() once. + def test_loader_child_contributions_install_during_on_install(self): + """Children registered by on_install install immediately. - setup() runs per list entry (so the plugin receives both entries), - but teardown() is an idempotent cleanup — a second call on a - plugin that has closed its resources would likely raise on an - already-closed connection. + The plugin list preserves parent-before-child order. Contribution + side effects happen when each plugin is added; FastMCP does not + defer or transact loader installs. """ - recorder = _Recorder() + + class Child(Plugin): + meta = PluginMeta(name="child", version="0.1.0") + + def middleware(self): + return [_TraceMiddleware("child")] + + class Loader(Plugin): + meta = PluginMeta(name="loader", version="0.1.0") + + def on_install(self, server): + server.add_plugin(Child()) + + def middleware(self): + return [_TraceMiddleware("loader")] + + mcp = FastMCP("t", plugins=[Loader()]) + + assert [p.meta.name for p in mcp.plugins] == ["loader", "child"] + tags = [m.tag for m in mcp.middleware if isinstance(m, _TraceMiddleware)] + assert tags == ["child", "loader"] + + def test_same_instance_registered_twice_raises(self): + """Plugin instances are single-server: a second registration of + the same instance (on any server) raises at `install()`.""" class P(Plugin): meta = PluginMeta(name="p", version="0.1.0") - async def teardown(self): - recorder.events.append(("teardown", "p")) - p = P() mcp = FastMCP("t") mcp.add_plugin(p) - mcp.add_plugin(p) - async with Client(mcp) as c: - await c.ping() + with pytest.raises(PluginError, match="already installed"): + mcp.add_plugin(p) - assert [e for e in recorder.events if e[0] == "teardown"] == [ - ("teardown", "p"), - ] + def test_instance_on_second_server_raises(self): + """Sharing a plugin instance across two servers is not supported.""" + + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + p = P() + FastMCP("a", plugins=[p]) + + with pytest.raises(PluginError, match="already installed"): + FastMCP("b", plugins=[p]) async def test_teardown_exception_is_logged_not_raised(self): class Boom(Plugin): @@ -1135,52 +1116,35 @@ class TestLifecycle: # BadSetup never completed setup(); its teardown must not run. assert ("teardown", "bad") not in recorder.events - async def test_contribution_collection_is_atomic_when_later_hook_raises(self): - """A failing hook on one plugin must not leave partial contributions behind. + def test_add_plugin_raises_when_later_hook_raises(self): + """Contribution hook errors fail loudly. - If a plugin's ``middleware()`` succeeds but ``transforms()`` - raises, the middleware must not have been installed — otherwise a - retry on the next lifespan attempt would pick up the plugin - again (because we never marked it contributed) and append - duplicate middleware on top of the partial prior state. + Plugin installation is not transactional: plugin hooks can mutate + arbitrary server state, so FastMCP does not pretend to recover a + partially configured server. Callers should discard the server + after a failed plugin install. """ class Flaky(Plugin): meta = PluginMeta(name="flaky", version="0.1.0") - _fail: bool = True def middleware(self): return [_TraceMiddleware("flaky")] def transforms(self): - if Flaky._fail: - raise RuntimeError("transforms exploded") - return [] + raise RuntimeError("transforms exploded") - mcp = FastMCP("t", plugins=[Flaky()]) - baseline = list(mcp.middleware) + mcp = FastMCP("t") + flaky = Flaky() with pytest.raises(RuntimeError, match="transforms exploded"): - async with Client(mcp) as c: - await c.ping() + mcp.add_plugin(flaky) - # Partial state from the failed cycle must not have landed. - assert mcp.middleware == baseline + assert flaky in mcp.plugins + assert flaky._installed_on is mcp - # Retry succeeds; middleware is installed exactly once. - Flaky._fail = False - async with Client(mcp) as c: - await c.ping() - - tags = [m.tag for m in mcp.middleware if isinstance(m, _TraceMiddleware)] - assert tags == ["flaky"] - - async def test_add_plugin_is_atomic_when_routes_raises(self): - """If plugin.routes() raises, the plugin must not be left in the server's list. - - Otherwise a later startup would run the half-registered plugin's - lifecycle even though registration reported an error. - """ + async def test_add_plugin_raises_when_routes_raises(self): + """Route hook errors fail loudly without best-effort recovery.""" class RoutesBoom(Plugin): meta = PluginMeta(name="routes-boom", version="0.1.0") @@ -1189,104 +1153,19 @@ class TestLifecycle: raise RuntimeError("routes exploded") mcp = FastMCP("t") + plugin = RoutesBoom() + with pytest.raises(RuntimeError, match="routes exploded"): - mcp.add_plugin(RoutesBoom()) + mcp.add_plugin(plugin) - assert mcp.plugins == [] - # Contribution book-keeping for the failed plugin was never created. - # This is a weaker assertion — we just care the plugin isn't linger. - assert not any(isinstance(p, RoutesBoom) for p in mcp.plugins) + assert plugin in mcp.plugins + assert plugin._installed_on is mcp - async def test_ephemeral_fastmcp_provider_is_removed_on_teardown(self): - """Loader-added FastMCP providers are auto-wrapped; teardown must still remove them. - - ``add_provider`` wraps a FastMCP in a FastMCPProvider before it - lands in ``self.providers``. Recording the pre-wrap object would - cause teardown to miss the wrapped provider and leak it across - cycles. - """ - - class ProviderPlugin(Plugin): - meta = PluginMeta(name="wrapper", version="0.1.0") - - def __init__(self, config=None): - super().__init__(config) - self._child = FastMCP("child") - - def providers(self): - return [self._child] - - class Loader(Plugin): - meta = PluginMeta(name="loader", version="0.1.0") - - async def setup(self, server): - server.add_plugin(ProviderPlugin()) - - mcp = FastMCP("t", plugins=[Loader()]) - baseline_providers = list(mcp.providers) - - async with Client(mcp) as c: - await c.ping() - async with Client(mcp) as c: - await c.ping() - - assert [p.meta.name for p in mcp.plugins] == ["loader"] - # The wrapped provider that was added on each cycle was removed - # on each teardown — the provider list is back to baseline. - assert mcp.providers == baseline_providers - - async def test_ephemeral_cleanup_removes_by_identity_not_equality(self): - """A permanent contribution that compares equal to an ephemeral one is preserved. - - list.remove() uses `==`, which is the wrong matcher when a - middleware defines value-based equality. A loader-added middleware - that happens to `==` a user-registered middleware must not cause - the user's to be removed during ephemeral cleanup. - """ - - class EqMiddleware(Middleware): - """Middleware that compares equal to any other EqMiddleware.""" - - def __eq__(self, other): - return isinstance(other, EqMiddleware) - - def __hash__(self): - return 0 - - permanent = EqMiddleware() - - class Child(Plugin): - meta = PluginMeta(name="child", version="0.1.0") - - def middleware(self): - # A distinct instance, but equal to `permanent` by __eq__. - return [EqMiddleware()] - - class Loader(Plugin): - meta = PluginMeta(name="loader", version="0.1.0") - - async def setup(self, server): - server.add_plugin(Child()) - - mcp = FastMCP("t", middleware=[permanent], plugins=[Loader()]) - assert permanent in mcp.middleware - - async with Client(mcp) as c: - await c.ping() - - # The ephemeral child's middleware was removed; the permanent - # user-registered one (which was `==` to it) is still installed. - assert any(m is permanent for m in mcp.middleware) - - async def test_reregistering_ephemeral_instance_as_permanent_clears_marker(self): - """A previously-ephemeral instance re-registered by the user is permanent. - - Without clearing the marker on normal `add_plugin`, the second - registration would inherit `_fastmcp_ephemeral = True` from the - first (loader-added) cycle and get deleted during teardown, losing - its contributions. - """ - leaked: list[Plugin] = [] + async def test_on_install_loader_contributions_persist_across_cycles(self): + """Plugins registered via a loader's `on_install(server)` are permanent + just like plugins passed to `FastMCP(plugins=[...])`. Their + contributions are installed once and survive across lifespan + cycles without re-registration or cleanup.""" class Child(Plugin): meta = PluginMeta(name="child", version="0.1.0") @@ -1297,80 +1176,29 @@ class TestLifecycle: class Loader(Plugin): meta = PluginMeta(name="loader", version="0.1.0") - async def setup(self, server): - # The loader is in control of the instance, so we can - # hand it back to the test via a closure. - child = Child() - leaked.append(child) - server.add_plugin(child) + def on_install(self, server): + server.add_plugin(Child()) mcp = FastMCP("t", plugins=[Loader()]) + # `on_install` ran during construction; child is registered. + assert [p.meta.name for p in mcp.plugins] == ["loader", "child"] + + # Contributions installed once, persist across multiple cycles. + async with Client(mcp) as c: + await c.ping() async with Client(mcp) as c: await c.ping() - # Ephemeral cleanup ran — child is no longer in the plugin list, - # and its middleware is gone. - assert [p.meta.name for p in mcp.plugins] == ["loader"] - child_instance = leaked[0] - assert child_instance._fastmcp_ephemeral is True - - # User re-registers the same instance as a permanent plugin. - mcp.add_plugin(child_instance) - assert child_instance._fastmcp_ephemeral is False - - async with Client(mcp) as c: - await c.ping() - - # After a second cycle, the permanent registration survives and - # its middleware is installed exactly once. - assert child_instance in mcp.plugins tags = [m.tag for m in mcp.middleware if isinstance(m, _TraceMiddleware)] assert tags == ["child"] - async def test_loader_plugins_do_not_accumulate_across_cycles(self): - """Loader-added (ephemeral) plugins and their contributions are removed on teardown. - - Without this, a loader that adds children in setup() causes the - plugin list — and every contribution those children install — to - grow on every lifespan cycle. - """ - - class Child(Plugin): - meta = PluginMeta(name="child", version="0.1.0") - - def middleware(self): - return [_TraceMiddleware("child")] - - class Loader(Plugin): - meta = PluginMeta(name="loader", version="0.1.0") - - async def setup(self, server): - server.add_plugin(Child()) - - mcp = FastMCP("t", plugins=[Loader()]) - baseline_middleware = list(mcp.middleware) - - async with Client(mcp) as c: - await c.ping() - async with Client(mcp) as c: - await c.ping() - async with Client(mcp) as c: - await c.ping() - - # After three cycles: the loader remains, the ephemeral child has - # been removed, and the middleware it installed was reversed out - # each time so nothing has accumulated. - assert [p.meta.name for p in mcp.plugins] == ["loader"] - assert mcp.middleware == baseline_middleware - class TestRunHook: """Plugins that override `run()` directly (the long-running pattern).""" async def test_run_override_wraps_server_lifetime(self): """A plugin overriding run() sees the server live between setup and teardown.""" - from contextlib import asynccontextmanager recorder = _Recorder() @@ -1397,7 +1225,6 @@ class TestRunHook: async def test_run_override_can_use_async_with(self): """A plugin's run() can acquire an async-context resource and release it on exit.""" - from contextlib import asynccontextmanager recorder = _Recorder() @@ -1432,7 +1259,6 @@ class TestRunHook: async def test_run_override_cancellation_propagates_into_background_task(self): """A long-running background task inside run() is cancelled on shutdown.""" - from contextlib import asynccontextmanager recorder = _Recorder() @@ -1464,7 +1290,6 @@ class TestRunHook: async def test_run_override_raising_before_yield_aborts_startup(self): """If a plugin's run() raises before yielding, startup fails cleanly.""" - from contextlib import asynccontextmanager class BadStart(Plugin): meta = PluginMeta(name="bad-start", version="0.1.0") @@ -1481,7 +1306,6 @@ class TestRunHook: async def test_run_override_composes_with_simple_setup_teardown_plugins(self): """A server can mix run-override plugins with setup/teardown plugins.""" - from contextlib import asynccontextmanager recorder = _Recorder() @@ -1732,6 +1556,28 @@ class TestPluginCapabilities: """Plugin with no override contributes nothing.""" assert _TestPlugin().capabilities() == {} + async def test_capabilities_are_collected_at_registration_time(self): + """Capability hooks are sync install-time contributions, not + lifespan-time callbacks.""" + calls = 0 + + class P(_TestPlugin): + def capabilities(self): + nonlocal calls + calls += 1 + return {"experimental": {"registered": {}}} + + mcp = FastMCP("t", plugins=[P()]) + assert calls == 1 + + async with Client(mcp) as c: + result = c.initialize_result + assert result is not None + experimental = result.capabilities.experimental or {} + assert experimental.get("registered") == {} + + assert calls == 1 + async def test_experimental_contribution_reaches_initialize_response(self): """An experimental capability entry flows through to the client.""" @@ -1862,15 +1708,16 @@ class TestPluginCapabilities: # A's cached dict must not have been mutated to contain B's entry. assert a._caps == {"experimental": {"alpha": {}}} - async def test_loader_added_plugin_capabilities_contribute(self): - """Plugins added via the loader pattern still contribute capabilities.""" + async def test_on_install_loader_plugin_capabilities_contribute(self): + """Plugins added via the on_install loader pattern contribute + capabilities just like plugins passed to `plugins=[...]`.""" class Loaded(_TestPlugin): def capabilities(self): return {"experimental": {"loaded": {}}} class Loader(_TestPlugin): - async def setup(self, server): + def on_install(self, server): server.add_plugin(Loaded()) mcp = FastMCP("t", plugins=[Loader()]) @@ -1880,3 +1727,30 @@ class TestPluginCapabilities: assert result is not None experimental = result.capabilities.experimental or {} assert experimental.get("loaded") == {} + + async def test_on_install_loader_capabilities_follow_plugin_order(self): + """Capabilities merge in plugin-list order even though child + plugins install while the parent's `on_install()` hook is running.""" + + class Loaded(_TestPlugin): + def capabilities(self): + return {"experimental": {"shared": {"owner": "child"}}} + + class Loader(_TestPlugin): + def on_install(self, server): + server.add_plugin(Loaded()) + + def capabilities(self): + return {"experimental": {"shared": {"owner": "loader"}}} + + mcp = FastMCP("t", plugins=[Loader()]) + assert [type(plugin).__name__ for plugin in mcp.plugins] == [ + "Loader", + "Loaded", + ] + + async with Client(mcp) as c: + result = c.initialize_result + assert result is not None + experimental = result.capabilities.experimental or {} + assert experimental.get("shared") == {"owner": "child"}