Add plugin auth hook and install-time contributions (#4022)

This commit is contained in:
Jeremiah Lowin 2026-05-04 12:09:33 -04:00 committed by GitHub
commit 5dc7baa5a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 596 additions and 487 deletions

View file

@ -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

View file

@ -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,

View file

@ -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.

View file

@ -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 '<name>'"` 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

View file

@ -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])

View file

@ -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"}