mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Add plugin auth hook and install-time contributions (#4022)
This commit is contained in:
parent
18ddf28b79
commit
5dc7baa5a8
6 changed files with 596 additions and 487 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
248
tests/server/plugins/test_auth_hook.py
Normal file
248
tests/server/plugins/test_auth_hook.py
Normal 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])
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue