From 823ea4c5fcc5b5aa16d1c78dfb9c092fe1f1c560 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:10:03 -0400 Subject: [PATCH 01/17] Add new FastMCP Plugin support (#3970) --- src/fastmcp/cli/cli.py | 4 + src/fastmcp/cli/plugin.py | 102 +++ src/fastmcp/server/mixins/lifespan.py | 9 + src/fastmcp/server/plugins/__init__.py | 15 + src/fastmcp/server/plugins/base.py | 317 +++++++++ src/fastmcp/server/server.py | 234 ++++++- tests/cli/test_plugin_cli.py | 95 +++ tests/server/test_plugins.py | 881 +++++++++++++++++++++++++ 8 files changed, 1643 insertions(+), 14 deletions(-) create mode 100644 src/fastmcp/cli/plugin.py create mode 100644 src/fastmcp/server/plugins/__init__.py create mode 100644 src/fastmcp/server/plugins/base.py create mode 100644 tests/cli/test_plugin_cli.py create mode 100644 tests/server/test_plugins.py diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 3c11fa35f..d412afc65 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -23,6 +23,7 @@ from fastmcp.cli.auth import auth_app from fastmcp.cli.client import call_command, discover_command, list_command from fastmcp.cli.generate import generate_cli_command from fastmcp.cli.install import install_app +from fastmcp.cli.plugin import plugin_app from fastmcp.cli.tasks import tasks_app from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config from fastmcp.utilities.inspect import ( @@ -1102,6 +1103,9 @@ app.command(install_app) # Add tasks subcommand group app.command(tasks_app) +# Add plugin subcommand group +app.command(plugin_app) + # Add client query commands app.command(list_command, name="list") app.command(call_command, name="call") diff --git a/src/fastmcp/cli/plugin.py b/src/fastmcp/cli/plugin.py new file mode 100644 index 000000000..4d7dda8d6 --- /dev/null +++ b/src/fastmcp/cli/plugin.py @@ -0,0 +1,102 @@ +"""CLI commands for working with FastMCP plugins. + +Currently exposes a single verb, `fastmcp plugin manifest`, which imports +a plugin class and emits its manifest (metadata + config schema + entry +point) as JSON. The manifest is the artifact downstream consumers +(Horizon, registries, CI tooling) ingest to discover and configure the +plugin without importing its module themselves. +""" + +from __future__ import annotations + +import importlib +import json +import sys +from pathlib import Path +from typing import Annotated + +import cyclopts +from cyclopts import Parameter + +from fastmcp.server.plugins import Plugin +from fastmcp.server.plugins.base import PluginError +from fastmcp.utilities.logging import get_logger + +logger = get_logger("cli.plugin") + +plugin_app = cyclopts.App( + name="plugin", + help="Work with FastMCP plugins.", + default_parameter=Parameter(negative=()), +) + + +def _resolve_plugin_class(entry_point: str) -> type[Plugin]: + """Import a plugin class from a `module.path:ClassName` spec. + + The class portion may be dotted (e.g. `module:Outer.MyPlugin`) to + resolve a nested class, matching the `entry_point` format + `Plugin.manifest()` emits via `__qualname__`. + """ + if ":" not in entry_point: + raise ValueError( + f"Invalid plugin reference {entry_point!r}: " + f"expected 'module.path:ClassName'" + ) + module_path, class_name = entry_point.split(":", 1) + try: + module = importlib.import_module(module_path) + except ImportError as exc: + raise ImportError(f"Could not import module {module_path!r}: {exc}") from exc + + cls: object = module + for part in class_name.split("."): + try: + cls = getattr(cls, part) + except AttributeError as exc: + raise AttributeError( + f"Module {module_path!r} has no attribute {class_name!r}" + ) from exc + + if not isinstance(cls, type) or not issubclass(cls, Plugin): + raise TypeError(f"{entry_point!r} does not refer to a fastmcp.Plugin subclass") + return cls + + +@plugin_app.command(name="manifest") +def manifest_command( + entry_point: Annotated[ + str, + Parameter(help="Plugin reference in 'module.path:ClassName' form."), + ], + output: Annotated[ + Path | None, + Parameter( + name=["--output", "-o"], + help="Write manifest JSON to this path instead of stdout.", + ), + ] = None, +) -> None: + """Emit a plugin's manifest as JSON. + + Imports the referenced plugin class and prints its manifest to stdout, + or writes it to the path given by `-o/--output`. + """ + try: + cls = _resolve_plugin_class(entry_point) + except (ImportError, AttributeError, TypeError, ValueError) as exc: + logger.error(str(exc)) + sys.exit(1) + + try: + manifest = cls.manifest() + except (PluginError, TypeError) as exc: + logger.error(str(exc)) + sys.exit(1) + + if output is None: + print(json.dumps(manifest, indent=2, sort_keys=False)) + return + + output.write_text(json.dumps(manifest, indent=2, sort_keys=False)) + print(f"Wrote manifest for {cls.meta.name} to {output}") diff --git a/src/fastmcp/server/mixins/lifespan.py b/src/fastmcp/server/mixins/lifespan.py index b64d725ed..6ee9e99e7 100644 --- a/src/fastmcp/server/mixins/lifespan.py +++ b/src/fastmcp/server/mixins/lifespan.py @@ -171,6 +171,15 @@ class LifespanMixin: self._lifespan_result = user_lifespan_result self._lifespan_result_set = True + # Plugin setup pass: runs before provider lifespans and _started. + # Plugins may contribute providers, so this must happen before + # we start their lifespans below. Register teardown BEFORE the + # setup pass so that a partial-setup failure (one plugin's + # setup() raises after earlier plugins already initialized) + # still triggers teardown for the plugins that completed. + stack.push_async_callback(self._run_plugin_teardown) + await self._run_plugin_setup_pass() + # Start lifespans for all providers for provider in self.providers: await stack.enter_async_context(provider.lifespan()) diff --git a/src/fastmcp/server/plugins/__init__.py b/src/fastmcp/server/plugins/__init__.py new file mode 100644 index 000000000..71af6dd1b --- /dev/null +++ b/src/fastmcp/server/plugins/__init__.py @@ -0,0 +1,15 @@ +"""FastMCP plugin primitive. + +Plugins are reusable, configurable units that contribute middleware, +transforms, providers, and custom HTTP routes to a FastMCP server. See +the design document for the full specification. + +Only the two user-facing primitives are re-exported here: `Plugin` +(subclass to define a plugin) and `PluginMeta` (the metadata model +plugins instantiate). Error classes live in `fastmcp.server.plugins.base` +and can be imported from there if needed. +""" + +from fastmcp.server.plugins.base import Plugin, PluginMeta + +__all__ = ["Plugin", "PluginMeta"] diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py new file mode 100644 index 000000000..de78d6aac --- /dev/null +++ b/src/fastmcp/server/plugins/base.py @@ -0,0 +1,317 @@ +"""Plugin primitive for FastMCP. + +Plugins package server-side behavior — middleware, component transforms, +providers, and custom HTTP routes — into reusable, configurable, +distributable units. A plugin is a subclass of `Plugin` with a +class-level `PluginMeta` and an optional nested `Config` model. + +See the design document for the full specification. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from pydantic import BaseModel, ConfigDict, ValidationError + +import fastmcp +from fastmcp.exceptions import FastMCPError +from fastmcp.server.middleware import Middleware +from fastmcp.server.providers import Provider +from fastmcp.server.transforms import Transform + +if TYPE_CHECKING: + from starlette.routing import BaseRoute + + from fastmcp.server.server import FastMCP + + +class PluginError(FastMCPError): + """Base class for plugin-related errors.""" + + +class PluginConfigError(PluginError): + """Raised when a plugin's configuration fails validation.""" + + +class PluginCompatibilityError(PluginError): + """Raised when a plugin declares a FastMCP version it is not compatible with.""" + + +class PluginMeta(BaseModel): + """Descriptive metadata for a plugin. + + Users who want typed custom fields subclass this model. Users who want + to attach ad-hoc fields without defining a model put them in the + `meta` dict. Unknown top-level fields are rejected to prevent future + collisions with standard fields. + """ + + name: str + """Plugin name. Required. Must be unique within a server.""" + + version: str + """Plugin version (plugin's own semver, independent of fastmcp).""" + + description: str | None = None + """Short human-readable description.""" + + tags: list[str] = [] + """Free-form tags for discovery and filtering.""" + + author: str | None = None + """Author identifier (person, team, or org).""" + + homepage: str | None = None + """Homepage URL.""" + + dependencies: list[str] = [] + """PEP 508 requirement specifiers for packages required to import and + run the plugin. Includes the plugin's own containing package plus any + runtime extras. FastMCP itself is implicit and must not be listed. + """ + + fastmcp_version: str | None = None + """Optional PEP 440 specifier expressing compatibility with FastMCP + core (e.g. `">=3.0"`). Verified at registration time. + """ + + meta: dict[str, Any] = {} + """Free-form bag for custom fields that have not been standardized. + Namespaced to prevent collisions with future standard fields. + """ + + model_config = ConfigDict(extra="forbid") + + +class Plugin: + """Base class for FastMCP plugins. + + Subclass to define a plugin. A subclass must declare a class-level + `meta` attribute (a `PluginMeta` instance). It may optionally + declare a nested `Config` (subclass of `pydantic.BaseModel`) + describing its configuration schema, and override any of the lifecycle + and contribution hooks. + + Example: + ```python + from fastmcp.server.plugins import Plugin, PluginMeta + from pydantic import BaseModel + + + class PIIRedactor(Plugin): + meta = PluginMeta( + name="pii-redactor", + version="0.3.0", + dependencies=[ + "fastmcp-plugin-pii>=0.3.0", + "regex>=2024.0", + ], + ) + + class Config(BaseModel): + patterns: list[str] = ["ssn", "email"] + + def middleware(self): + return [PIIMiddleware(self.config)] + ``` + """ + + meta: ClassVar[PluginMeta] + """Class-level metadata. Required on every subclass.""" + + class Config(BaseModel): + """Default empty configuration. Subclasses override to declare fields.""" + + model_config = ConfigDict(extra="forbid") + + config: BaseModel + + # 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 + + def __init__(self, config: BaseModel | dict[str, Any] | None = None) -> None: + # A subclass's nested Config is a distinct class from Plugin.Config; + # we accept any BaseModel instance here and validate at runtime that + # it's (or coerces to) the subclass's own Config type. This is why + # `config` is typed as BaseModel rather than the nested Config — the + # nested declaration does not imply subclass relationship. + meta = getattr(type(self), "meta", None) + if not isinstance(meta, PluginMeta): + raise TypeError( + f"{type(self).__name__} must declare a class-level " + f"'meta' attribute of type PluginMeta" + ) + self._validate_meta(meta) + + config_cls = type(self).Config + if config is None: + value: BaseModel = config_cls() + elif isinstance(config, config_cls): + value = config + elif isinstance(config, dict): + try: + value = config_cls(**config) + except ValidationError as exc: + raise PluginConfigError( + f"Invalid configuration for {type(self).__name__}: {exc}" + ) from exc + else: + raise PluginConfigError( + f"Config for {type(self).__name__} must be a {config_cls.__name__} " + f"instance or dict, not {type(config).__name__}" + ) + self.config = value + + # -- validation ----------------------------------------------------------- + + @staticmethod + def _validate_meta(meta: PluginMeta) -> None: + """Check that the plugin's declared metadata is internally consistent.""" + for dep in meta.dependencies: + try: + req = Requirement(dep) + except InvalidRequirement as exc: + raise PluginError( + f"Plugin {meta.name!r}: invalid PEP 508 requirement {dep!r}: {exc}" + ) from exc + if req.name.lower().replace("_", "-") == "fastmcp": + raise PluginError( + f"Plugin {meta.name!r}: 'fastmcp' must not appear in " + f"dependencies. Use the 'fastmcp_version' field instead." + ) + + if meta.fastmcp_version is not None: + try: + SpecifierSet(meta.fastmcp_version) + except InvalidSpecifier as exc: + raise PluginError( + f"Plugin {meta.name!r}: invalid fastmcp_version " + f"specifier {meta.fastmcp_version!r}: {exc}" + ) from exc + + def check_fastmcp_compatibility(self) -> None: + """Raise if the declared `fastmcp_version` excludes the running FastMCP.""" + spec_str = self.meta.fastmcp_version + if spec_str is None: + return + spec = SpecifierSet(spec_str) + current = fastmcp.__version__ + if current not in spec: + raise PluginCompatibilityError( + f"Plugin {self.meta.name!r} requires fastmcp {spec_str}, " + f"but running fastmcp is {current}." + ) + + # -- lifecycle ------------------------------------------------------------ + + async def setup(self, server: FastMCP) -> None: + """Called on each lifespan cycle during the server's setup pass, before the server binds. + + Receives the server it's attaching to; may call + `server.add_plugin()` to register additional plugins (used by + loader plugins). Async so that plugins can open database + connections, warm HTTP clients, or otherwise perform + `await`-able initialization. Plugins must not assume other + plugins are present during their own `setup()` — the full list + may not yet be populated. + """ + + async def teardown(self) -> None: + """Called on each lifespan cycle when the server shuts down, in reverse registration order. + + Async so that plugins can close connections, flush buffers, or + otherwise perform `await`-able cleanup. + """ + + # -- contribution hooks --------------------------------------------------- + + def middleware(self) -> list[Middleware]: + """Return MCP-layer middleware to install on the server.""" + return [] + + def transforms(self) -> list[Transform]: + """Return component transforms (tools, resources, prompts).""" + return [] + + def providers(self) -> list[Provider]: + """Return component providers.""" + return [] + + def routes(self) -> list[BaseRoute]: + """Return custom HTTP routes to mount on the server's ASGI app. + + Routes contributed here are **not authenticated by the framework** + — the MCP auth provider does not gate them. They are appropriate + for webhook endpoints whose callers carry their own authentication + scheme (e.g. an HMAC-signed header), and the plugin is responsible + for verifying inbound requests inside the handler. + + Routes otherwise receive the full incoming HTTP request unchanged, + including all headers the client sent. If a caller has provided + the same credentials it would use for an authenticated MCP call, + those headers are available on `request.headers` for the handler + to inspect — the plugin chooses whether and how to validate them. + """ + return [] + + # -- introspection -------------------------------------------------------- + + @classmethod + def manifest( + cls, + path: str | Path | None = None, + ) -> dict[str, Any] | None: + """Return the plugin's manifest as a dict, or write it to `path` as JSON. + + Does not instantiate the plugin. The manifest is a JSON-serializable + dict that combines the plugin's metadata, its config schema, and an + importable entry point. Downstream consumers (Horizon, registries, + CI tooling) read the manifest to discover plugins and render + configuration forms without installing the plugin's dependencies. + """ + meta = getattr(cls, "meta", None) + if not isinstance(meta, PluginMeta): + raise TypeError( + f"{cls.__name__} must declare a class-level " + f"'meta' attribute of type PluginMeta" + ) + + # Validate meta the same way instance construction does, so + # `fastmcp plugin manifest` can't emit an artifact (malformed + # PEP 508 deps, bad fastmcp_version specifier, fastmcp declared + # as a dep, ...) that downstream tooling couldn't otherwise + # have produced from a live plugin instance. + cls._validate_meta(meta) + + config_cls = getattr(cls, "Config", Plugin.Config) + data: dict[str, Any] = { + "manifest_version": 1, + **meta.model_dump(), + "config_schema": config_cls.model_json_schema(), + "entry_point": f"{cls.__module__}:{cls.__qualname__}", + } + + if path is None: + return data + + target = Path(path) + target.write_text(json.dumps(data, indent=2, sort_keys=False)) + return None + + +__all__ = [ + "Plugin", + "PluginCompatibilityError", + "PluginConfigError", + "PluginError", + "PluginMeta", +] diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 2f00e2017..f9550dc66 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -68,6 +68,8 @@ from fastmcp.server.lifespan import Lifespan from fastmcp.server.low_level import LowLevelServer from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.server.mixins import LifespanMixin, MCPOperationsMixin, TransportMixin +from fastmcp.server.plugins import Plugin +from fastmcp.server.plugins.base import PluginError from fastmcp.server.providers import LocalProvider, Provider from fastmcp.server.providers.aggregate import AggregateProvider from fastmcp.server.tasks.config import TaskConfig, TaskMeta @@ -185,16 +187,16 @@ def _get_auth_context() -> tuple[bool, Any]: def _is_model_visible(tool: Tool) -> bool: """Check whether a tool should be visible to the model. - Tools registered via ``@app.tool()`` (without ``model=True``) have - ``meta["ui"]["visibility"] == ["app"]`` — they are callable by app UIs + Tools registered via `@app.tool()` (without `model=True`) have + `meta["ui"]["visibility"] == ["app"]` — they are callable by app UIs but should not appear in the model's tool list. Returns True (visible) when: - - The tool has no ``meta.ui.visibility`` (normal tools). - - ``"model"`` is in the visibility list (e.g. ``["model"]`` or ``["app", "model"]``). + - The tool has no `meta.ui.visibility` (normal tools). + - `"model"` is in the visibility list (e.g. `["model"]` or `["app", "model"]`). - Returns False when the visibility list exists and does not contain ``"model"`` - (e.g. ``["app"]``). + Returns False when the visibility list exists and does not contain `"model"` + (e.g. `["app"]`). """ meta = tool.meta if not meta: @@ -212,8 +214,8 @@ def _is_app_visible(tool: Tool) -> bool: """Check whether a tool has explicitly opted into app-callable visibility. Gates the dispatcher's hashed-name routing path: only tools whose - ``meta.ui.visibility`` list contains ``"app"`` can be reached via - ``_`` calls. Tools without an explicit visibility + `meta.ui.visibility` list contains `"app"` can be reached via + `_` calls. Tools without an explicit visibility declaration are NOT app-callable — they must be reached by their display name through the normal transform-aware resolution path. @@ -295,6 +297,7 @@ class FastMCP( middleware: Sequence[Middleware] | None = None, providers: Sequence[Provider] | None = None, transforms: Sequence[Transform] | None = None, + plugins: Sequence[Plugin] | None = None, lifespan: LifespanCallable | Lifespan | None = None, tools: Sequence[Tool | Callable[..., Any]] | None = None, on_duplicate: DuplicateBehavior | None = None, @@ -414,6 +417,33 @@ class FastMCP( self.middleware.append(DereferenceRefsMiddleware()) + # Plugin registry: an ordered list, populated by `add_plugin()` and + # `plugins=[...]`. Setup and contribution collection happen during + # the server's lifespan startup (see `_run_plugin_setup_pass`). + 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 setup() has completed successfully in the current + # lifespan cycle. Reset on teardown. Used to scope teardown to + # plugins that actually ran setup, so a partial-setup failure + # still triggers teardown for everything that was initialized. + self._plugins_set_up: 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 setup pass is executing. `add_plugin()` uses this + # to flag ephemeral plugins — plugins added from inside another + # plugin's setup() (the loader pattern). Ephemeral plugins are + # removed on teardown so loaders can freshly re-hydrate children + # on the next lifespan cycle without accumulating duplicates. + self._in_plugin_setup_pass: bool = False + for p in plugins or []: + self.add_plugin(p) + # Set up MCP protocol handlers self._setup_handlers() @@ -478,6 +508,182 @@ class FastMCP( def add_middleware(self, middleware: Middleware) -> None: self.middleware.append(middleware) + 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 during `setup()`. + + 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. + + Loader caveat: plugins added from inside another plugin's + `setup()` (the loader pattern) can still contribute middleware, + transforms, and providers, but their routes may not be reachable + over HTTP/SSE transports — those transports' route lists are + already fixed by the time `setup()` runs. Loaders that need to + contribute routes should use the stdio transport or expose the + routes via a non-loader plugin registered at construction time. + + Raises: + PluginError: If called after the server has started, or if the + plugin's `fastmcp_version` compatibility check fails. + + Args: + plugin: A :class:`Plugin` instance. Plugins are registered in + the order they are added; middleware is a stack. + """ + if self._started.is_set(): + raise PluginError( + f"Cannot add plugin {plugin.meta.name!r}: the server has " + "already started. Register plugins before the server binds." + ) + 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()) + 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: + self._additional_http_routes.append(route) + records.append((self._additional_http_routes, route)) + + async def _run_plugin_setup_pass(self) -> None: + """Run setup() on every registered plugin and collect contributions. + + Called during server startup (from `_lifespan_manager`), before + the server binds. Iterates the plugin list in order, awaiting + `setup(server)` on each; plugins added during another plugin's + setup (the loader pattern) are picked up by the same loop because + the iteration advances against a live index. + + Setup runs every lifespan cycle — plugins expect a matching + `setup`/`teardown` pair. Contribution collection is one-shot + per plugin: once a plugin's middleware/transforms/providers/routes + have been installed on the server they persist across cycles, + matching how server-authored middleware behaves. + """ + # Setup pass: mutating-list iteration. New plugins appended by a + # plugin's setup() are picked up on subsequent iterations. We mark + # each plugin as "set up" only after setup() returns so that a + # partial-setup failure scopes teardown to plugins that actually + # completed initialization. The _in_plugin_setup_pass flag lets + # add_plugin() mark new plugins as ephemeral (see add_plugin). + self._in_plugin_setup_pass = True + try: + i = 0 + while i < len(self.plugins): + plugin = self.plugins[i] + await plugin.setup(self) + self._plugins_set_up.add(id(plugin)) + i += 1 + + # 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 setup) still get their contributions collected. Note: + # routes are collected synchronously at add_plugin() time + # because HTTP transports snapshot the route list before the + # lifespan runs. + 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)) + finally: + self._in_plugin_setup_pass = False + + async def _run_plugin_teardown(self) -> None: + """Await `teardown()` on plugins whose setup() completed this cycle. + + Iterates the plugin list in reverse registration order and only + tears down plugins that were successfully set up. This makes + teardown safe to register before `_run_plugin_setup_pass` — a + failure partway through setup still runs teardown for the plugins + that had already initialized. + + Ephemeral plugins (those added from inside another plugin's + setup() by a loader) are removed from the server after teardown + runs, along with the contributions they installed. This keeps + loader plugins from accumulating duplicate children across + repeated lifespan cycles. + """ + # Snapshot + clear first so that a slow teardown combined with a + # re-entry cannot double-count the set. + set_up = self._plugins_set_up + self._plugins_set_up = set() + for plugin in reversed(self.plugins): + if id(plugin) not in set_up: + continue + # Discard first so a plugin that appears twice in the list + # (same instance registered twice — see test_duplicates_allowed) + # only gets torn down once, even though its setup() ran once + # per list entry. + set_up.discard(id(plugin)) + try: + await plugin.teardown() + except Exception: + logger.exception("Plugin %r raised during teardown", plugin.meta.name) + + # Remove ephemeral plugins and their contributions. On the next + # lifespan cycle, the loader that produced them will freshly + # re-hydrate its children — without this cleanup, the plugin list + # and contribution registries would grow on every cycle. + 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) + ] + def add_provider(self, provider: Provider, *, namespace: str = "") -> None: """Add a provider for dynamic tools, resources, and prompts. @@ -497,10 +703,10 @@ class FastMCP( def _rewrite_prefab_uris(self, tools: list[Tool]) -> list[Tool]: """Replace placeholder Prefab URIs with per-tool hashed ones. - For each tool whose ``meta.ui.resourceUri`` is the placeholder, - reads the tool's stored hash from ``meta.fastmcp._tool_hash`` + For each tool whose `meta.ui.resourceUri` is the placeholder, + reads the tool's stored hash from `meta.fastmcp._tool_hash` and rewrites the URI to the per-tool form. Also strips CSP from - tool meta (it belongs on the resource). Produces ``model_copy`` + tool meta (it belongs on the resource). Produces `model_copy` views — originals are untouched. """ from fastmcp.server.providers.prefab_synthesis import ( @@ -573,7 +779,7 @@ class FastMCP( """Add a tool transformation. .. deprecated:: - Use ``add_transform(ToolTransform({...}))`` instead. + Use `add_transform(ToolTransform({...}))` instead. """ if fastmcp.settings.deprecation_warnings: warnings.warn( @@ -1569,7 +1775,7 @@ class FastMCP( """Remove tool(s) from the server. .. deprecated:: - Use ``mcp.local_provider.remove_tool(name)`` instead. + Use `mcp.local_provider.remove_tool(name)` instead. Args: name: The name of the tool to remove. @@ -2137,7 +2343,7 @@ class FastMCP( optionally with a given prefix. .. deprecated:: - Use :meth:`mount` instead. ``import_server`` will be removed in a + Use :meth:`mount` instead. `import_server` will be removed in a future version. Note that when a server is *imported*, its objects are immediately diff --git a/tests/cli/test_plugin_cli.py b/tests/cli/test_plugin_cli.py new file mode 100644 index 000000000..553499a77 --- /dev/null +++ b/tests/cli/test_plugin_cli.py @@ -0,0 +1,95 @@ +"""Tests for the `fastmcp plugin` CLI.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path + + +def _run_fastmcp( + *args: str, cwd: Path, env_extra: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + """Invoke the `fastmcp` CLI as a subprocess.""" + env = os.environ.copy() + env["PYTHONPATH"] = str(cwd) + if env_extra: + env.update(env_extra) + return subprocess.run( + [sys.executable, "-m", "fastmcp.cli", *args], + cwd=cwd, + capture_output=True, + text=True, + env=env, + ) + + +class TestManifestCLI: + def test_manifest_for_top_level_class(self, tmp_path: Path): + (tmp_path / "demo.py").write_text( + textwrap.dedent( + """ + from fastmcp.server.plugins import Plugin, PluginMeta + + class Demo(Plugin): + meta = PluginMeta(name="demo", version="0.1.0") + """ + ) + ) + result = _run_fastmcp("plugin", "manifest", "demo:Demo", cwd=tmp_path) + assert result.returncode == 0, result.stderr + manifest = json.loads(result.stdout) + assert manifest["name"] == "demo" + assert manifest["entry_point"] == "demo:Demo" + + def test_manifest_for_nested_class(self, tmp_path: Path): + """`__qualname__` produces dotted paths for nested classes; the CLI + must traverse the dots to resolve the inner class.""" + (tmp_path / "demo.py").write_text( + textwrap.dedent( + """ + from fastmcp.server.plugins import Plugin, PluginMeta + + class Outer: + class Inner(Plugin): + meta = PluginMeta(name="inner", version="0.1.0") + """ + ) + ) + result = _run_fastmcp("plugin", "manifest", "demo:Outer.Inner", cwd=tmp_path) + assert result.returncode == 0, result.stderr + manifest = json.loads(result.stdout) + assert manifest["name"] == "inner" + assert manifest["entry_point"] == "demo:Outer.Inner" + + def test_manifest_emits_clean_error_for_invalid_meta(self, tmp_path: Path): + """A plugin with invalid meta must produce a clean error, not a traceback.""" + (tmp_path / "bad.py").write_text( + textwrap.dedent( + """ + from fastmcp.server.plugins import Plugin, PluginMeta + + class Bad(Plugin): + meta = PluginMeta( + name="bad", + version="0.1.0", + dependencies=["not a valid pep508 spec!!"], + ) + """ + ) + ) + result = _run_fastmcp("plugin", "manifest", "bad:Bad", cwd=tmp_path) + assert result.returncode == 1 + # Error goes through logger.error, not as a Python traceback. + assert "Traceback" not in result.stderr + assert "PEP 508" in result.stderr + + def test_manifest_emits_clean_error_for_missing_module(self, tmp_path: Path): + result = _run_fastmcp( + "plugin", "manifest", "nonexistent_module:Thing", cwd=tmp_path + ) + assert result.returncode == 1 + assert "Traceback" not in result.stderr diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py new file mode 100644 index 000000000..ae70dcf53 --- /dev/null +++ b/tests/server/test_plugins.py @@ -0,0 +1,881 @@ +"""Tests for the FastMCP plugin primitive.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import BaseModel + +import fastmcp +from fastmcp import Client, FastMCP +from fastmcp.server.middleware import Middleware +from fastmcp.server.plugins import Plugin, PluginMeta +from fastmcp.server.plugins.base import ( + PluginCompatibilityError, + PluginConfigError, + PluginError, +) + + +class _TraceMiddleware(Middleware): + """Tiny identity middleware tagged by name so we can see it in a stack.""" + + def __init__(self, tag: str) -> None: + self.tag = tag + + +class _Recorder: + """Shared record of plugin lifecycle events for assertions in tests.""" + + def __init__(self) -> None: + self.events: list[tuple[str, str]] = [] + + +class TestPluginMeta: + """PluginMeta is the source-of-truth metadata model.""" + + def test_required_fields(self): + meta = PluginMeta(name="x", version="0.1.0") + assert meta.name == "x" + assert meta.version == "0.1.0" + assert meta.description is None + assert meta.tags == [] + assert meta.dependencies == [] + assert meta.fastmcp_version is None + assert meta.meta == {} + + def test_unknown_top_level_field_rejected(self): + with pytest.raises(Exception): + PluginMeta(name="x", version="0.1.0", owning_team="platform") # ty: ignore[unknown-argument] + + def test_custom_fields_allowed_under_meta_dict(self): + meta = PluginMeta( + name="x", + version="0.1.0", + meta={"owning_team": "platform", "maintainer": "jlowin"}, + ) + assert meta.meta["owning_team"] == "platform" + + def test_subclass_can_add_typed_fields(self): + class AcmeMeta(PluginMeta): + owning_team: str + + meta = AcmeMeta(name="x", version="0.1.0", owning_team="platform") + assert meta.owning_team == "platform" + + +class TestPluginConstruction: + """Plugin construction validates meta and config at instantiation time.""" + + def test_plugin_without_meta_raises(self): + class NoMeta(Plugin): + pass + + with pytest.raises(TypeError, match="meta"): + NoMeta() + + def test_plugin_with_default_config(self): + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + p = P() + assert isinstance(p.config, Plugin.Config) + + def test_config_accepts_instance(self): + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + class Config(BaseModel): + who: str = "world" + + p = P(config=P.Config(who="jeremiah")) + assert isinstance(p.config, P.Config) + assert p.config.who == "jeremiah" + + def test_config_accepts_dict(self): + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + class Config(BaseModel): + who: str = "world" + + p = P(config={"who": "jeremiah"}) + assert isinstance(p.config, P.Config) + assert p.config.who == "jeremiah" + + def test_invalid_config_raises_plugin_config_error(self): + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + class Config(BaseModel): + count: int + + with pytest.raises(PluginConfigError): + P(config={"count": "not a number"}) + + def test_bad_config_type_raises(self): + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + with pytest.raises(PluginConfigError): + P(config="not a config") # ty: ignore[invalid-argument-type] + + +class TestPluginValidation: + """Meta validation rejects malformed values eagerly.""" + + def test_fastmcp_in_dependencies_rejected(self): + class Bad(Plugin): + meta = PluginMeta( + name="bad", + version="0.1.0", + dependencies=["fastmcp>=3.0"], + ) + + with pytest.raises(PluginError, match="fastmcp"): + Bad() + + def test_invalid_dependency_spec_rejected(self): + class Bad(Plugin): + meta = PluginMeta( + name="bad", + version="0.1.0", + dependencies=["not a valid pep508 spec!!"], + ) + + with pytest.raises(PluginError, match="PEP 508"): + Bad() + + def test_invalid_fastmcp_version_spec_rejected(self): + class Bad(Plugin): + meta = PluginMeta( + name="bad", + version="0.1.0", + fastmcp_version="not-a-specifier", + ) + + with pytest.raises(PluginError, match="fastmcp_version"): + Bad() + + def test_incompatible_fastmcp_version_raises(self, monkeypatch): + # Pin the version we're checking against so the test doesn't depend + # on whatever build-time version the running interpreter has (CI + # builds can resolve to "0.0.0" via uv-dynamic-versioning's + # fallback, which would match specifiers like "<0.1"). + monkeypatch.setattr(fastmcp, "__version__", "3.0.0") + + class Incompat(Plugin): + meta = PluginMeta( + name="incompat", + version="0.1.0", + fastmcp_version=">=100.0.0", + ) + + with pytest.raises(PluginCompatibilityError): + Incompat().check_fastmcp_compatibility() + + +class TestRegistration: + """Plugins register before startup; add_plugin is a list append.""" + + def test_plugins_kwarg_registers(self): + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + mcp = FastMCP("t", plugins=[P(), P()]) + assert [p.meta.name for p in mcp.plugins] == ["p", "p"] + + def test_add_plugin_appends(self): + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + mcp = FastMCP("t") + mcp.add_plugin(P()) + mcp.add_plugin(P()) + assert len(mcp.plugins) == 2 + + def test_duplicates_allowed(self): + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + mcp = FastMCP("t") + mcp.add_plugin(P()) + mcp.add_plugin(P()) + # No dedup, no warn, no raise. + assert len(mcp.plugins) == 2 + + def test_add_plugin_checks_fastmcp_version_at_registration(self, monkeypatch): + monkeypatch.setattr(fastmcp, "__version__", "3.0.0") + + class Incompat(Plugin): + meta = PluginMeta( + name="incompat", + version="0.1.0", + fastmcp_version=">=100.0.0", + ) + + mcp = FastMCP("t") + with pytest.raises(PluginCompatibilityError): + mcp.add_plugin(Incompat()) + + def test_add_plugin_does_not_call_setup(self): + """setup() runs during startup, not at add_plugin.""" + + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + async def setup(self, server): + raise AssertionError("setup should not run at registration time") + + mcp = FastMCP("t") + mcp.add_plugin(P()) # must not raise + + +class TestLifecycle: + """Setup and teardown run during the server's lifespan.""" + + async def test_setup_runs_during_startup(self): + recorder = _Recorder() + + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + async def setup(self, server): + recorder.events.append(("setup", "p")) + + async def teardown(self): + recorder.events.append(("teardown", "p")) + + mcp = FastMCP("t", plugins=[P()]) + async with Client(mcp) as c: + await c.ping() + assert recorder.events == [("setup", "p"), ("teardown", "p")] + + async def test_setup_order_follows_registration(self): + recorder = _Recorder() + + def make(name: str) -> type[Plugin]: + class _P(Plugin): + meta = PluginMeta(name=name, version="0.1.0") + + async def setup(self, server): + recorder.events.append(("setup", name)) + + async def teardown(self): + recorder.events.append(("teardown", name)) + + return _P + + A, B, C = make("a"), make("b"), make("c") + mcp = FastMCP("t", plugins=[A(), B()]) + mcp.add_plugin(C()) + + async with Client(mcp) as c: + await c.ping() + + # Setup in registration order; teardown reversed. + assert [e for e in recorder.events if e[0] == "setup"] == [ + ("setup", "a"), + ("setup", "b"), + ("setup", "c"), + ] + assert [e for e in recorder.events if e[0] == "teardown"] == [ + ("teardown", "c"), + ("teardown", "b"), + ("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. + """ + recorder = _Recorder() + + class Child(Plugin): + meta = PluginMeta(name="child", version="0.1.0") + + async def setup(self, server): + recorder.events.append(("setup", "child")) + + 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()) + + 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"), + ] + # 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="already started"): + mcp.add_plugin(P()) + + async def test_duplicate_registration_tears_down_once(self): + """Registering the same instance twice must only call teardown() once. + + 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. + """ + recorder = _Recorder() + + 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() + + assert [e for e in recorder.events if e[0] == "teardown"] == [ + ("teardown", "p"), + ] + + async def test_teardown_exception_is_logged_not_raised(self): + class Boom(Plugin): + meta = PluginMeta(name="boom", version="0.1.0") + + async def teardown(self): + raise RuntimeError("boom") + + mcp = FastMCP("t", plugins=[Boom()]) + # Should not raise out of the client context manager. + async with Client(mcp) as c: + await c.ping() + + async def test_setup_and_teardown_run_on_every_lifespan_cycle(self): + """A server reused across multiple lifespan cycles re-runs setup/teardown.""" + recorder = _Recorder() + + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + async def setup(self, server): + recorder.events.append(("setup", "p")) + + async def teardown(self): + recorder.events.append(("teardown", "p")) + + mcp = FastMCP("t", plugins=[P()]) + + async with Client(mcp) as c: + await c.ping() + async with Client(mcp) as c: + await c.ping() + + # Both cycles run setup and teardown; a one-shot guard would have + # skipped the second cycle. + assert recorder.events == [ + ("setup", "p"), + ("teardown", "p"), + ("setup", "p"), + ("teardown", "p"), + ] + + async def test_contributions_not_doubled_across_lifespan_cycles(self): + """Contribution hooks are collected once per plugin, not per cycle.""" + + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + def middleware(self): + return [_TraceMiddleware("p")] + + mcp = FastMCP("t", plugins=[P()]) + + async with Client(mcp) as c: + await c.ping() + async with Client(mcp) as c: + await c.ping() + + tags = [m.tag for m in mcp.middleware if isinstance(m, _TraceMiddleware)] + assert tags == ["p"] + + async def test_teardown_runs_for_plugins_that_set_up_when_later_plugin_fails(self): + """Partial-setup failure still triggers teardown on already-initialized plugins.""" + recorder = _Recorder() + + class Good(Plugin): + meta = PluginMeta(name="good", version="0.1.0") + + async def setup(self, server): + recorder.events.append(("setup", "good")) + + async def teardown(self): + recorder.events.append(("teardown", "good")) + + class BadSetup(Plugin): + meta = PluginMeta(name="bad", version="0.1.0") + + async def setup(self, server): + recorder.events.append(("setup", "bad")) + raise RuntimeError("setup failed") + + async def teardown(self): + # Must not be called — setup() never completed. + recorder.events.append(("teardown", "bad")) + + mcp = FastMCP("t", plugins=[Good(), BadSetup()]) + + with pytest.raises(RuntimeError, match="setup failed"): + async with Client(mcp) as c: + await c.ping() + + assert ("setup", "good") in recorder.events + assert ("setup", "bad") in recorder.events + assert ("teardown", "good") in recorder.events + # 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. + + 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. + """ + + 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 [] + + mcp = FastMCP("t", plugins=[Flaky()]) + baseline = list(mcp.middleware) + + with pytest.raises(RuntimeError, match="transforms exploded"): + async with Client(mcp) as c: + await c.ping() + + # Partial state from the failed cycle must not have landed. + assert mcp.middleware == baseline + + # 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. + """ + + class RoutesBoom(Plugin): + meta = PluginMeta(name="routes-boom", version="0.1.0") + + def routes(self): + raise RuntimeError("routes exploded") + + mcp = FastMCP("t") + with pytest.raises(RuntimeError, match="routes exploded"): + mcp.add_plugin(RoutesBoom()) + + 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) + + 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] = [] + + 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): + # 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) + + mcp = FastMCP("t", plugins=[Loader()]) + + 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 TestContributions: + """Plugin contributions are installed during the setup pass.""" + + async def test_middleware_contribution(self): + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + def middleware(self): + return [_TraceMiddleware("p")] + + mcp = FastMCP("t", plugins=[P()]) + async with Client(mcp) as c: + await c.ping() + + tags = [m.tag for m in mcp.middleware if isinstance(m, _TraceMiddleware)] + assert tags == ["p"] + + async def test_contribution_order_follows_registration(self): + class P(Plugin): + def __init__(self, name: str) -> None: + super().__init__() + self._name = name + + meta = PluginMeta(name="p", version="0.1.0") + + def middleware(self): + return [_TraceMiddleware(self._name)] + + a, b = P("a"), P("b") + mcp = FastMCP("t", plugins=[a, b]) + async with Client(mcp) as c: + await c.ping() + + tags = [m.tag for m in mcp.middleware if isinstance(m, _TraceMiddleware)] + assert tags == ["a", "b"] + + async def test_custom_route_contribution(self): + from starlette.responses import JSONResponse + from starlette.routing import Route + + async def health(request): + return JSONResponse({"ok": True}) + + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + def routes(self): + return [Route("/healthz", endpoint=health, methods=["GET"])] + + mcp = FastMCP("t", plugins=[P()]) + async with Client(mcp) as c: + await c.ping() + + assert any( + getattr(r, "path", None) == "/healthz" for r in mcp._additional_http_routes + ) + + def test_plugin_route_mounted_on_http_app(self): + """Plugin routes must be in place before http_app() snapshots routes. + + Regression test for collecting routes at ``add_plugin()`` time + rather than during the lifespan's setup pass. HTTP transports + call ``_get_additional_http_routes()`` at app construction, which + happens before the lifespan runs; routes added during setup would + sit in ``_additional_http_routes`` but never be mounted and would + always 404. + """ + + def _walk_paths(routes): + for route in routes: + path = getattr(route, "path", None) + if path is not None: + yield path + inner = getattr(route, "routes", None) + if inner: + yield from _walk_paths(inner) + + from starlette.responses import JSONResponse + from starlette.routing import Route + + async def health(request): + return JSONResponse({"ok": True}) + + class Health(Plugin): + meta = PluginMeta(name="health", version="0.1.0") + + def routes(self): + return [Route("/healthz", endpoint=health, methods=["GET"])] + + mcp = FastMCP("t", plugins=[Health()]) + app = mcp.http_app() + + paths = set(_walk_paths(app.router.routes)) + assert "/healthz" in paths + + +class TestManifest: + """manifest() produces a JSON-serializable dict and can write to disk.""" + + def test_manifest_shape(self): + class P(Plugin): + meta = PluginMeta( + name="p", + version="0.1.0", + description="demo", + tags=["x"], + dependencies=["demo>=0.1"], + fastmcp_version=">=3.0", + meta={"owning_team": "platform"}, + ) + + class Config(BaseModel): + who: str = "world" + + m = P.manifest() + assert m is not None + assert m["manifest_version"] == 1 + assert m["name"] == "p" + assert m["version"] == "0.1.0" + assert m["description"] == "demo" + assert m["tags"] == ["x"] + assert m["dependencies"] == ["demo>=0.1"] + assert m["fastmcp_version"] == ">=3.0" + assert m["meta"] == {"owning_team": "platform"} + assert ":" in m["entry_point"] + assert m["entry_point"].endswith(".P") + assert m["config_schema"]["type"] == "object" + assert "who" in m["config_schema"]["properties"] + + def test_manifest_custom_fields_subclass(self): + class AcmeMeta(PluginMeta): + owning_team: str + + class P(Plugin): + meta = AcmeMeta(name="p", version="0.1.0", owning_team="platform") + + m = P.manifest() + assert m is not None + assert m["owning_team"] == "platform" + + def test_manifest_write_to_path(self, tmp_path: Path): + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + out = tmp_path / "plugin.json" + result = P.manifest(path=out) + assert result is None + data = json.loads(out.read_text()) + assert data["name"] == "p" + + def test_manifest_does_not_instantiate(self): + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + def __init__(self, *_args, **_kwargs): # type: ignore[no-untyped-def] + raise AssertionError("manifest() must not instantiate the plugin") + + # Should succeed without calling __init__. + assert P.manifest() is not None + + def test_manifest_validates_meta(self): + """Invalid meta (e.g. malformed deps) must not emit a manifest. + + Otherwise `fastmcp plugin manifest` could publish artifacts with + malformed PEP 508 dep strings or bad fastmcp_version specifiers — + artifacts that downstream tooling can't parse consistently. + """ + + class BadDeps(Plugin): + meta = PluginMeta( + name="bad-deps", + version="0.1.0", + dependencies=["not a valid pep508 spec!!"], + ) + + with pytest.raises(PluginError, match="PEP 508"): + BadDeps.manifest() + + class FastmcpInDeps(Plugin): + meta = PluginMeta( + name="fastmcp-in-deps", + version="0.1.0", + dependencies=["fastmcp>=3.0"], + ) + + with pytest.raises(PluginError, match="fastmcp"): + FastmcpInDeps.manifest() From 54e83367a3b0f2c4a428fecb518cfba6798322d7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:23:14 -0400 Subject: [PATCH 02/17] Replace plugin setup/teardown with run(server) async context manager (#3972) --- CLAUDE.md | 1 + src/fastmcp/server/mixins/lifespan.py | 16 +-- src/fastmcp/server/plugins/base.py | 66 +++++++-- src/fastmcp/server/server.py | 192 ++++++++++++-------------- tests/server/test_plugins.py | 155 +++++++++++++++++++++ 5 files changed, 310 insertions(+), 120 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3add49c73..db34995f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ When modifying MCP functionality, changes typically need to be applied across al - **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so. - **NEVER** merge a PR marked as do-not-merge or draft. Check title, body, AND labels for `[DNM]`, `DNM`, `DO NOT MERGE`, `DON'T MERGE`, `DONT MERGE`, `do-not-merge`, `dont-merge`, `[DRAFT]`, or `DRAFT` (case-insensitive, any variation — some authors use `[DRAFT]` in the title even when `isDraft` is false). Authors use these as hard stops — respect them even if CI is green and review looks clean. When triaging a batch of PRs, filter these out up front AND re-check each one's labels immediately before merging, since labels can change mid-session. - **ALWAYS** read review-bot comments before approving a PR. CodeRabbit and chatgpt-codex-connector (Codex) leave substantive review comments on most PRs in this repo — these bots have read the diff and often flag real issues that aren't in the PR description. Use `gh pr view --comments` and read the bot feedback as part of review. Unlike proposed solutions from issue reporters, review-bot feedback should be evaluated on its merits, not discounted. +- **Be constructively skeptical of bot review comments on your own PRs.** CodeRabbit, Codex, and claude[bot] run a fresh review pass on every push, which means a PR with active churn can accumulate bot comments in a stream that never really ends — each fix surfaces a new edge case the next pass can flag. Most of the early feedback is real and worth acting on; diminishing returns set in fast. Evaluate each comment on its merits, the same way you would a human reviewer: is this a real bug users will hit, or a hypothetical that requires an adversarial setup? Does the fix introduce more complexity than the problem? Has the bot missed context that's obvious to a human reader (a `*,` keyword-only marker, a design decision documented elsewhere, something already resolved on a later commit)? When a comment is pedantic, a false positive, or flagging something already fixed, reply on the thread explaining the reasoning and move on — don't keep iterating just because more comments arrive. If you find yourself three rounds deep and the feedback is shifting toward "what if someone does X" hypotheticals, you're past the point where each fix is improving the PR. Stop, document the contract as-is, and ship. ### Releases diff --git a/src/fastmcp/server/mixins/lifespan.py b/src/fastmcp/server/mixins/lifespan.py index 6ee9e99e7..951a2bbc6 100644 --- a/src/fastmcp/server/mixins/lifespan.py +++ b/src/fastmcp/server/mixins/lifespan.py @@ -171,14 +171,14 @@ class LifespanMixin: self._lifespan_result = user_lifespan_result self._lifespan_result_set = True - # Plugin setup pass: runs before provider lifespans and _started. - # Plugins may contribute providers, so this must happen before - # we start their lifespans below. Register teardown BEFORE the - # setup pass so that a partial-setup failure (one plugin's - # setup() raises after earlier plugins already initialized) - # still triggers teardown for the plugins that completed. - stack.push_async_callback(self._run_plugin_teardown) - await self._run_plugin_setup_pass() + # 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. + await self._enter_plugin_contexts(stack) # Start lifespans for all providers for provider in self.providers: diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py index de78d6aac..5a06fcad8 100644 --- a/src/fastmcp/server/plugins/base.py +++ b/src/fastmcp/server/plugins/base.py @@ -11,6 +11,8 @@ See the design document for the full specification. from __future__ import annotations import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar @@ -23,6 +25,9 @@ from fastmcp.exceptions import FastMCPError from fastmcp.server.middleware import Middleware from fastmcp.server.providers import Provider from fastmcp.server.transforms import Transform +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) if TYPE_CHECKING: from starlette.routing import BaseRoute @@ -213,23 +218,60 @@ class Plugin: # -- lifecycle ------------------------------------------------------------ - async def setup(self, server: FastMCP) -> None: - """Called on each lifespan cycle during the server's setup pass, before the server binds. + @asynccontextmanager + async def run(self, server: FastMCP) -> AsyncIterator[None]: + """Async context manager wrapping the plugin's lifetime. - Receives the server it's attaching to; may call - `server.add_plugin()` to register additional plugins (used by - loader plugins). Async so that plugins can open database - connections, warm HTTP clients, or otherwise perform - `await`-able initialization. Plugins must not assume other - plugins are present during their own `setup()` — the full list - may not yet be populated. + 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. + + 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: + + @asynccontextmanager + async def run(self, server): + async with httpx.AsyncClient() as client: + self.client = client + yield + """ + await self.setup(server) + try: + yield + finally: + try: + await self.teardown() + except Exception: + # Exceptions during teardown are logged, not raised, so a + # broken plugin can't take down the server's shutdown + # sequence. Plugins that want different semantics should + # override `run()` directly. + logger.exception("Plugin %r raised during teardown", self.meta.name) + + async def setup(self, server: FastMCP) -> None: + """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`. """ async def teardown(self) -> None: - """Called on each lifespan cycle when the server shuts down, in reverse registration order. + """One-shot async cleanup. Called by the default `run()` after + the `yield`. - Async so that plugins can close connections, flush buffers, or - otherwise perform `await`-able cleanup. + Override for simple cleanup work — close connections, flush + buffers. For resource management that would benefit from + `async with`, override `run()` directly instead. """ # -- contribution hooks --------------------------------------------------- diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index f9550dc66..c75b4173d 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -15,6 +15,7 @@ from collections.abc import ( ) from contextlib import ( AbstractAsyncContextManager, + AsyncExitStack, asynccontextmanager, ) from dataclasses import replace @@ -418,28 +419,27 @@ class FastMCP( self.middleware.append(DereferenceRefsMiddleware()) # Plugin registry: an ordered list, populated by `add_plugin()` and - # `plugins=[...]`. Setup and contribution collection happen during - # the server's lifespan startup (see `_run_plugin_setup_pass`). + # `plugins=[...]`. 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 setup() has completed successfully in the current - # lifespan cycle. Reset on teardown. Used to scope teardown to - # plugins that actually ran setup, so a partial-setup failure - # still triggers teardown for everything that was initialized. - self._plugins_set_up: 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 setup pass is executing. `add_plugin()` uses this - # to flag ephemeral plugins — plugins added from inside another - # plugin's setup() (the loader pattern). Ephemeral plugins are - # removed on teardown so loaders can freshly re-hydrate children - # on the next lifespan cycle without accumulating duplicates. + # 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 for p in plugins or []: self.add_plugin(p) @@ -562,110 +562,102 @@ class FastMCP( self._additional_http_routes.append(route) records.append((self._additional_http_routes, route)) - async def _run_plugin_setup_pass(self) -> None: - """Run setup() on every registered plugin and collect contributions. + 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, awaiting - `setup(server)` on each; plugins added during another plugin's - setup (the loader pattern) are picked up by the same loop because - the iteration advances against a live index. + 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. - Setup runs every lifespan cycle — plugins expect a matching - `setup`/`teardown` pair. Contribution collection is one-shot - per plugin: once a plugin's middleware/transforms/providers/routes - have been installed on the server they persist across cycles, - matching how server-authored middleware behaves. + 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. """ - # Setup pass: mutating-list iteration. New plugins appended by a - # plugin's setup() are picked up on subsequent iterations. We mark - # each plugin as "set up" only after setup() returns so that a - # partial-setup failure scopes teardown to plugins that actually - # completed initialization. The _in_plugin_setup_pass flag lets - # add_plugin() mark new plugins as ephemeral (see add_plugin). + # 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] - await plugin.setup(self) - self._plugins_set_up.add(id(plugin)) i += 1 - - # 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 setup) still get their contributions collected. Note: - # routes are collected synchronously at add_plugin() time - # because HTTP transports snapshot the route list before the - # lifespan runs. - for plugin in self.plugins: - if id(plugin) in self._plugins_contributed: + if id(plugin) in self._plugins_entered: 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)) + 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 - async def _run_plugin_teardown(self) -> None: - """Await `teardown()` on plugins whose setup() completed this cycle. - - Iterates the plugin list in reverse registration order and only - tears down plugins that were successfully set up. This makes - teardown safe to register before `_run_plugin_setup_pass` — a - failure partway through setup still runs teardown for the plugins - that had already initialized. - - Ephemeral plugins (those added from inside another plugin's - setup() by a loader) are removed from the server after teardown - runs, along with the contributions they installed. This keeps - loader plugins from accumulating duplicate children across - repeated lifespan cycles. - """ - # Snapshot + clear first so that a slow teardown combined with a - # re-entry cannot double-count the set. - set_up = self._plugins_set_up - self._plugins_set_up = set() - for plugin in reversed(self.plugins): - if id(plugin) not in set_up: + # 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 - # Discard first so a plugin that appears twice in the list - # (same instance registered twice — see test_duplicates_allowed) - # only gets torn down once, even though its setup() ran once - # per list entry. - set_up.discard(id(plugin)) - try: - await plugin.teardown() - except Exception: - logger.exception("Plugin %r raised during teardown", plugin.meta.name) + # 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() - # Remove ephemeral plugins and their contributions. On the next - # lifespan cycle, the loader that produced them will freshly - # re-hydrate its children — without this cleanup, the plugin list - # and contribution registries would grow on every cycle. ephemeral = [p for p in self.plugins if getattr(p, "_fastmcp_ephemeral", False)] for plugin in ephemeral: records = self._plugin_contributions.pop(id(plugin), []) diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index ae70dcf53..fff9e7b71 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -2,7 +2,9 @@ from __future__ import annotations +import asyncio import json +from contextlib import suppress from pathlib import Path import pytest @@ -692,6 +694,159 @@ class TestLifecycle: 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() + + class Long(Plugin): + meta = PluginMeta(name="long", version="0.1.0") + + @asynccontextmanager + async def run(self, server): + recorder.events.append(("enter", "long")) + try: + yield + finally: + recorder.events.append(("exit", "long")) + + mcp = FastMCP("t", plugins=[Long()]) + async with Client(mcp) as c: + await c.ping() + # Mid-cycle: enter fired, exit hasn't. + assert ("enter", "long") in recorder.events + assert ("exit", "long") not in recorder.events + + # After teardown: both fired. + assert recorder.events == [("enter", "long"), ("exit", "long")] + + 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() + + @asynccontextmanager + async def fake_resource(): + recorder.events.append(("acquire", "resource")) + try: + yield "handle" + finally: + recorder.events.append(("release", "resource")) + + class WithResource(Plugin): + meta = PluginMeta(name="with-resource", version="0.1.0") + + @asynccontextmanager + async def run(self, server): + async with fake_resource() as handle: + self.handle = handle + yield + + p = WithResource() + mcp = FastMCP("t", plugins=[p]) + async with Client(mcp) as c: + await c.ping() + assert p.handle == "handle" + + # async with cleanup fired on exit path + assert recorder.events == [ + ("acquire", "resource"), + ("release", "resource"), + ] + + 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() + + class Background(Plugin): + meta = PluginMeta(name="background", version="0.1.0") + + @asynccontextmanager + async def run(self, server): + async def worker(): + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + recorder.events.append(("cancelled", "worker")) + raise + + task = asyncio.create_task(worker()) + try: + yield + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + mcp = FastMCP("t", plugins=[Background()]) + async with Client(mcp) as c: + await c.ping() + + assert recorder.events == [("cancelled", "worker")] + + 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") + + @asynccontextmanager + async def run(self, server): + raise RuntimeError("cannot start") + yield # unreachable + + mcp = FastMCP("t", plugins=[BadStart()]) + with pytest.raises(RuntimeError, match="cannot start"): + async with Client(mcp) as c: + await c.ping() + + 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() + + class Simple(Plugin): + meta = PluginMeta(name="simple", version="0.1.0") + + async def setup(self, server): + recorder.events.append(("setup", "simple")) + + async def teardown(self): + recorder.events.append(("teardown", "simple")) + + class LongRunning(Plugin): + meta = PluginMeta(name="long-running", version="0.1.0") + + @asynccontextmanager + async def run(self, server): + recorder.events.append(("enter", "long-running")) + try: + yield + finally: + recorder.events.append(("exit", "long-running")) + + mcp = FastMCP("t", plugins=[Simple(), LongRunning()]) + async with Client(mcp) as c: + await c.ping() + + # Enter order follows registration; exit order is reversed. + assert recorder.events == [ + ("setup", "simple"), + ("enter", "long-running"), + ("exit", "long-running"), + ("teardown", "simple"), + ] + + class TestContributions: """Plugin contributions are installed during the setup pass.""" From 769e998017196deccca67687662c01eff82bc839 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:22:27 -0400 Subject: [PATCH 03/17] Reject plugin registration after the setup pass completes (#3973) --- src/fastmcp/server/server.py | 36 ++++++++++++++++++++---------- tests/server/test_plugins.py | 43 +++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c75b4173d..3731b0f92 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -513,9 +513,9 @@ class FastMCP( Appends the plugin to the server's ordered plugin list and synchronously collects its HTTP routes (see below). Middleware, - transforms, and providers are collected later, during the server's - startup sequence, because those hooks may reference state the - plugin populates during `setup()`. + transforms, and providers are collected later, during the + server's startup sequence, because those hooks may reference + state the plugin populates while its `run()` context is active. HTTP routes are collected eagerly because HTTP transports snapshot the server's route list when they construct the Starlette app — @@ -526,25 +526,37 @@ class FastMCP( working for HTTP transports. Loader caveat: plugins added from inside another plugin's - `setup()` (the loader pattern) can still contribute middleware, + `run()` (the loader pattern) can still contribute middleware, transforms, and providers, but their routes may not be reachable over HTTP/SSE transports — those transports' route lists are - already fixed by the time `setup()` runs. Loaders that need to + already fixed by the time `run()` enters. Loaders that need to contribute routes should use the stdio transport or expose the routes via a non-loader plugin registered at construction time. - Raises: - PluginError: If called after the server has started, or if the - plugin's `fastmcp_version` compatibility check fails. - Args: plugin: A :class:`Plugin` instance. Plugins are registered in the order they are added; middleware is a stack. + + Raises: + PluginError: If called after the server's plugin-entry pass + has completed (except from inside a loader plugin's + `run()`), or if the plugin's `fastmcp_version` + compatibility check fails. """ - if self._started.is_set(): + # Reject registration once the lifespan is active and we're past + # the plugin-entry pass. The loader-pattern exception is the only + # case where add_plugin() runs during a live lifespan, and it's + # gated by `_in_plugin_setup_pass`. Checking `_started` alone is + # too narrow: `_started` is only set after provider lifespans + # enter and is cleared before teardown, leaving windows in which + # add_plugin() would silently register a plugin whose `run()` + # never enters for the current cycle. + if self._lifespan_result_set and not self._in_plugin_setup_pass: raise PluginError( - f"Cannot add plugin {plugin.meta.name!r}: the server has " - "already started. Register plugins before the server binds." + f"Cannot add plugin {plugin.meta.name!r}: the server's " + "plugin-entry pass has already completed. Register " + "plugins before the server starts, or from inside " + "another plugin's `run()` (the loader pattern)." ) plugin.check_fastmcp_compatibility() # Compute routes up front so a failure inside plugin.routes() does diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index fff9e7b71..4f02dc676 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -337,9 +337,50 @@ class TestLifecycle: mcp = FastMCP("t") async with Client(mcp) as c: await c.ping() - with pytest.raises(PluginError, match="already started"): + with pytest.raises(PluginError, match="plugin-entry pass"): mcp.add_plugin(P()) + async def test_add_plugin_raises_when_called_from_provider_lifespan(self): + """Post-setup-pass registration must be rejected, not silently allowed. + + `_started` is set only after provider lifespans enter, so a + provider's `lifespan()` callback runs with `_started` False but + the plugin-entry pass already complete. Registering a plugin in + that window would skip `run()` and contribution collection for + the current cycle and leave the plugin in `self.plugins`; the + guard must reject it. + """ + from contextlib import asynccontextmanager + + from fastmcp.server.providers import Provider + + class PluginInProviderLifespan(Provider): + def __init__(self, server): + super().__init__() + self.server = server + self.raised: Exception | None = None + + @asynccontextmanager + async def lifespan(self): + class Late(Plugin): + meta = PluginMeta(name="late", version="0.1.0") + + try: + self.server.add_plugin(Late()) + except Exception as exc: + self.raised = exc + yield + + mcp = FastMCP("t") + provider = PluginInProviderLifespan(mcp) + mcp.add_provider(provider) + + async with Client(mcp) as c: + await c.ping() + + assert isinstance(provider.raised, PluginError) + assert "plugin-entry pass" in str(provider.raised) + async def test_duplicate_registration_tears_down_once(self): """Registering the same instance twice must only call teardown() once. From e2e49f77d29114a32d3183cdd4abb91f8baacfe5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 19 Apr 2026 08:56:42 -0400 Subject: [PATCH 04/17] Add PluginMeta.from_package() helper (#3974) --- src/fastmcp/server/plugins/base.py | 148 +++++++++++++++++++++++- tests/server/test_plugins.py | 173 ++++++++++++++++++++++++++++- 2 files changed, 319 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py index 5a06fcad8..6781304f2 100644 --- a/src/fastmcp/server/plugins/base.py +++ b/src/fastmcp/server/plugins/base.py @@ -13,12 +13,17 @@ from __future__ import annotations import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from email.message import Message as EmailMessage +from importlib import metadata as importlib_metadata from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, cast from packaging.requirements import InvalidRequirement, Requirement from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version from pydantic import BaseModel, ConfigDict, ValidationError +from typing_extensions import Self import fastmcp from fastmcp.exceptions import FastMCPError @@ -92,6 +97,147 @@ class PluginMeta(BaseModel): model_config = ConfigDict(extra="forbid") + @classmethod + def from_package(cls, distribution: str, /, **overrides: Any) -> Self: + """Derive plugin metadata from an installed Python distribution. + + Reads `version`, `description`, `author`, and `homepage` from the + distribution's metadata (as recorded in its `pyproject.toml` and + exposed via `importlib.metadata`), and pins the distribution + itself as the sole entry in `dependencies` — so the manifest + automatically reflects the containing package and stays in sync + with every new release. Runtime dependencies declared in the + distribution's `Requires-Dist` are NOT harvested; plugin authors + pass additional runtime deps via the `dependencies` override. + + Any keyword argument overrides the derived value. + + Example: + ```python + class MyPiiRedactor(Plugin): + meta = PluginMeta.from_package( + "fastmcp-plugin-my-pii", # distribution name on PyPI + name="my-pii", # plugin identifier + tags=["security"], + ) + ``` + + Args: + distribution: The installed distribution name to read from + (e.g. `"fastmcp-plugin-my-pii"`). Must be importable via + `importlib.metadata`. Cannot be `fastmcp` itself — use + `fastmcp_version` for core compatibility. + **overrides: Any `PluginMeta` field. Overrides take precedence + over the derived value. `name` is required unless a + `name` override is supplied; the distribution name is not + used as the plugin name by default since the two serve + different purposes (distribution = wheel identity, plugin + name = runtime identifier shown to Horizon / CLI users). + + Raises: + PluginError: If the distribution is not installed in the + current environment, if `distribution` is `fastmcp` + (which would produce an invalid manifest), or if the + distribution's version cannot be parsed. + """ + # FastMCP itself is implicit; pinning it would produce a manifest + # that Plugin._validate_meta rejects. Plugin authors expressing + # core compatibility should use the `fastmcp_version` field. + if canonicalize_name(distribution) == "fastmcp": + raise PluginError( + f"PluginMeta.from_package({distribution!r}): " + f"`fastmcp` is implicit and must not be used as the " + f"containing distribution. Use the `fastmcp_version` " + f"field on PluginMeta to express core compatibility." + ) + + try: + dist = importlib_metadata.distribution(distribution) + except importlib_metadata.PackageNotFoundError as exc: + raise PluginError( + f"PluginMeta.from_package({distribution!r}): distribution " + f"is not installed in the current environment. Install it " + f"(e.g. via `uv pip install {distribution}`) before " + f"calling from_package." + ) from exc + + # `dist.metadata` is an email.message.Message at runtime, but + # `importlib.metadata.PackageMetadata`'s stubs don't expose that + # interface. Cast to email.message.Message to flatten header + # access (item lookup returns None on miss; `items()` yields one + # entry per header, including repeated keys like Project-URL). + raw = cast(EmailMessage, dist.metadata) + headers: dict[str, str] = {} + all_project_urls: list[str] = [] + for key, value in raw.items(): + if key == "Project-URL": + all_project_urls.append(value) + else: + # For repeated headers we only need one; first-wins. + headers.setdefault(key, value) + + def _first_non_blank(*values: str | None) -> str | None: + """Return the first value whose `.strip()` is truthy, or None. + + Guards against whitespace-only headers silently blocking the + fallback chain (e.g. a METADATA file with `Author: ` would + otherwise make the `Author-email` fallback unreachable). + """ + for v in values: + if v is not None and v.strip(): + return v.strip() + return None + + derived: dict[str, Any] = {"version": dist.version} + + # description ← Summary header + summary = _first_non_blank(headers.get("Summary")) + if summary: + derived["description"] = summary + + # author ← Author, falling back to Author-email + author = _first_non_blank(headers.get("Author"), headers.get("Author-email")) + if author: + derived["author"] = author + + # homepage ← Home-page, falling back to the first Project-URL + # whose label looks like a canonical homepage reference + homepage = _first_non_blank(headers.get("Home-page")) + if not homepage: + for entry in all_project_urls: + # Project-URL values are `"Label, URL"` pairs. + label, _, url = entry.partition(",") + if label.strip().lower() in { + "homepage", + "home", + "repository", + "source", + }: + homepage = _first_non_blank(url) + if homepage: + break + if homepage: + derived["homepage"] = homepage + + # dependencies — pin the containing distribution at its current + # version, minus the local segment. PEP 440 only restricts local + # versions (`+abc.def`) from use with `>=` / `<=`; prereleases + # (`rc1`), dev (`.dev0`), and post segments are all valid there, + # so we preserve them to keep the pin meaningful for actively + # developed distributions. `Version.public` strips exactly the + # local segment. + try: + public = Version(dist.version).public + except InvalidVersion as exc: + raise PluginError( + f"PluginMeta.from_package({distribution!r}): could not " + f"parse distribution version {dist.version!r}: {exc}" + ) from exc + derived["dependencies"] = [f"{distribution}>={public}"] + + derived.update(overrides) + return cls(**derived) + class Plugin: """Base class for FastMCP plugins. diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index 4f02dc676..f70598bd7 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -5,10 +5,13 @@ from __future__ import annotations import asyncio import json from contextlib import suppress +from importlib import metadata as importlib_metadata +from importlib.metadata import version as dist_version from pathlib import Path import pytest -from pydantic import BaseModel +from packaging.version import Version +from pydantic import BaseModel, ValidationError import fastmcp from fastmcp import Client, FastMCP @@ -68,6 +71,174 @@ class TestPluginMeta: assert meta.owning_team == "platform" +class TestFromPackage: + """PluginMeta.from_package() derives metadata from importlib.metadata.""" + + # pydantic is a hard dependency of fastmcp, so it's always installed + # in the test environment and has well-formed metadata we can read. + # We deliberately don't use fastmcp itself as the smoke-test + # distribution because from_package() refuses to pin fastmcp (see + # test_fastmcp_as_distribution_is_rejected). + + def test_derives_version_description_from_real_package(self): + meta = PluginMeta.from_package("pydantic", name="pydantic-smoke-test") + + assert meta.name == "pydantic-smoke-test" + assert meta.version == dist_version("pydantic") + # Description is whatever pydantic itself declares; only assert + # that the field is populated. + assert meta.description is not None + # Dep pin uses `Version.public` (strips only local segment; + # preserves pre/dev/post, which ARE valid with `>=` per PEP 440). + public = Version(dist_version("pydantic")).public + assert meta.dependencies == [f"pydantic>={public}"] + + def test_overrides_take_precedence(self): + meta = PluginMeta.from_package( + "pydantic", + name="override-test", + version="99.0.0", + description="I override the derived description", + tags=["security"], + ) + assert meta.version == "99.0.0" + assert meta.description == "I override the derived description" + assert meta.tags == ["security"] + + def test_overriding_dependencies_replaces_the_pin(self): + """If a plugin author passes dependencies explicitly, the containing + distribution pin isn't re-added — author owns the list.""" + meta = PluginMeta.from_package( + "pydantic", + name="custom-deps", + dependencies=["regex>=2024.0"], + ) + assert meta.dependencies == ["regex>=2024.0"] + + def test_missing_distribution_raises_plugin_error(self): + with pytest.raises(PluginError, match="not installed"): + PluginMeta.from_package( + "this-package-definitely-does-not-exist-1234abcd", + name="missing", + ) + + def test_fastmcp_as_distribution_is_rejected(self): + """`fastmcp` is implicit per the primitive contract; pinning it + in `dependencies` would produce a manifest that fails validation.""" + with pytest.raises(PluginError, match="implicit"): + PluginMeta.from_package("fastmcp", name="would-be-fastmcp-plugin") + + def test_fastmcp_rejection_is_case_insensitive(self): + """PEP 503 canonicalization lowercases the distribution name, so + `FastMCP` and `FASTMCP` both canonicalize to `fastmcp` and must + be rejected. `fast-mcp` / `fast_mcp` canonicalize to `fast-mcp` + — a different distribution — and are not rejected here.""" + for variant in ("FastMCP", "FASTMCP", "fAsTmCp"): + with pytest.raises(PluginError, match="implicit"): + PluginMeta.from_package(variant, name="x") + + @pytest.mark.parametrize( + "dist_version_str, expected_pin", + [ + # Pre/dev/post segments are valid with `>=` and must be + # preserved so the pin tracks prerelease channels accurately. + ("1.2.3.dev0", "synthetic-pin>=1.2.3.dev0"), + ("1.2.3rc1", "synthetic-pin>=1.2.3rc1"), + ("1.2.3.post1", "synthetic-pin>=1.2.3.post1"), + # Local versions are NOT valid with `>=` per PEP 440; we + # strip only that segment via Version.public. + ("1.2.3+abc.def", "synthetic-pin>=1.2.3"), + # Dev build with a local segment: strip just the local. + ("1.2.3.dev5+abc123", "synthetic-pin>=1.2.3.dev5"), + # Plain release — unchanged. + ("2.0.0", "synthetic-pin>=2.0.0"), + ], + ) + def test_pin_preserves_pre_dev_post_but_strips_local( + self, monkeypatch, dist_version_str, expected_pin + ): + """PEP 440 restricts only local versions from `>=` / `<=`; + prereleases, dev, and post segments remain valid. The pin uses + `Version.public` (strips only the local segment) so development + channels keep their identity in the generated pin.""" + real_distribution = importlib_metadata.distribution + + class FakeDist: + version = dist_version_str + + def __init__(self): + self.metadata = real_distribution("pydantic").metadata + + def fake_distribution(name): + if name == "synthetic-pin": + return FakeDist() + return real_distribution(name) + + # `from_package` reaches `importlib_metadata.distribution` through + # the `plugins.base` module's alias; patch there. + from fastmcp.server.plugins import base as plugins_base + + monkeypatch.setattr( + plugins_base.importlib_metadata, "distribution", fake_distribution + ) + + meta = PluginMeta.from_package("synthetic-pin", name="pin-test") + assert meta.dependencies == [expected_pin] + + # Resulting meta round-trips through _validate_meta cleanly. + Plugin._validate_meta(meta) + + def test_whitespace_only_author_header_falls_back_to_email(self, monkeypatch): + """A METADATA file with `Author: ` (whitespace only) must not + block the `Author-email` fallback. Similar for `Home-page` + falling back to Project-URL.""" + real_distribution = importlib_metadata.distribution + pydantic_metadata = real_distribution("pydantic").metadata + + class FakeMessage: + def items(self): + return [ + ("Metadata-Version", "2.1"), + ("Name", "whitespace-test"), + ("Version", "1.0.0"), + ("Author", " "), # whitespace only + ("Author-email", "real@example.com"), + ("Home-page", ""), # empty + ("Project-URL", "Homepage, https://example.com"), + ] + + class FakeDist: + version = "1.0.0" + metadata = FakeMessage() + + def fake_distribution(name): + if name == "whitespace-test": + return FakeDist() + return real_distribution(name) + + from fastmcp.server.plugins import base as plugins_base + + monkeypatch.setattr( + plugins_base.importlib_metadata, "distribution", fake_distribution + ) + + meta = PluginMeta.from_package("whitespace-test", name="ws-test") + # Whitespace Author didn't block Author-email. + assert meta.author == "real@example.com" + # Empty Home-page fell through to the Project-URL label match. + assert meta.homepage == "https://example.com" + + # Avoid "pydantic_metadata unused" lint noise. + _ = pydantic_metadata + + def test_name_override_required_if_not_provided(self): + """`name` is required on PluginMeta; from_package doesn't default + it from the distribution name (plugin name and distribution name + serve different purposes).""" + with pytest.raises(ValidationError): + PluginMeta.from_package("pydantic") + + class TestPluginConstruction: """Plugin construction validates meta and config at instantiation time.""" From ff0ae10d8871d1a043c706df788b2620e1cc2379 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:41:47 -0400 Subject: [PATCH 05/17] Add Plugin.capabilities() hook and auto-derive Plugin.meta (#3982) --- src/fastmcp/server/low_level.py | 4 + src/fastmcp/server/plugins/base.py | 74 +++++++++- src/fastmcp/server/server.py | 24 ++++ src/fastmcp/utilities/collections.py | 26 ++++ tests/server/test_plugins.py | 195 ++++++++++++++++++++++++++- 5 files changed, 313 insertions(+), 10 deletions(-) create mode 100644 src/fastmcp/utilities/collections.py diff --git a/src/fastmcp/server/low_level.py b/src/fastmcp/server/low_level.py index 36255f4c7..90d48b1b8 100644 --- a/src/fastmcp/server/low_level.py +++ b/src/fastmcp/server/low_level.py @@ -220,6 +220,10 @@ class LowLevelServer(_Server[LifespanResultT, RequestT]): ) capabilities.extensions = {**existing_extensions, UI_EXTENSION_ID: {}} + # Plugin contributions apply last so plugins can override built-in + # defaults. See FastMCP._apply_plugin_capabilities for merge rules. + capabilities = self.fastmcp._apply_plugin_capabilities(capabilities) + return capabilities async def run( diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py index 6781304f2..200498a62 100644 --- a/src/fastmcp/server/plugins/base.py +++ b/src/fastmcp/server/plugins/base.py @@ -11,6 +11,7 @@ See the design document for the full specification. from __future__ import annotations import json +import re from collections.abc import AsyncIterator from contextlib import asynccontextmanager from email.message import Message as EmailMessage @@ -239,14 +240,36 @@ class PluginMeta(BaseModel): return cls(**derived) +_DEFAULT_PLUGIN_VERSION = "0.1.0" + + +def _derive_plugin_name(cls_name: str) -> str: + """Kebab-case a class name, stripping a trailing ``Plugin`` suffix. + + `ChannelPlugin` → `"channel"`, `CodeMode` → `"code-mode"`, + `PIIRedactor` → `"pii-redactor"`. + """ + # Split acronym from following capitalized word: `PIIRedactor` → `PII-Redactor` + name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", cls_name) + # Split lowercase/digit from following uppercase: `CodeMode` → `Code-Mode` + name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name) + name = name.lower() + if name.endswith("-plugin") and name != "-plugin": + name = name[: -len("-plugin")] + return name + + class Plugin: """Base class for FastMCP plugins. - Subclass to define a plugin. A subclass must declare a class-level - `meta` attribute (a `PluginMeta` instance). It may optionally - declare a nested `Config` (subclass of `pydantic.BaseModel`) - describing its configuration schema, and override any of the lifecycle - and contribution hooks. + Subclass to define a plugin. A subclass may optionally declare a + class-level `meta` attribute (a `PluginMeta` instance); if omitted, + a default is derived from the class name (kebab-cased, trailing + `Plugin` stripped) with version `0.1.0`. Declare `meta` explicitly + when publishing or when Horizon/registry-facing metadata matters. + Subclasses may also declare a nested `Config` (subclass of + `pydantic.BaseModel`) describing configuration, and override any of + the lifecycle and contribution hooks. Example: ```python @@ -273,7 +296,24 @@ class Plugin: """ meta: ClassVar[PluginMeta] - """Class-level metadata. Required on every subclass.""" + """Class-level metadata. Auto-derived from the class name and a + placeholder version if the subclass doesn't declare one — fine for + in-code use. Declare `meta = PluginMeta(...)` (or + `PluginMeta.from_package(...)`) explicitly when publishing or when + Horizon/registry-facing metadata matters. + """ + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + # Auto-derive meta if the subclass didn't declare its own. We + # check `cls.__dict__` rather than attribute lookup so inherited + # meta from an intermediate subclass isn't treated as a local + # declaration — each concrete Plugin class gets its own name. + if "meta" not in cls.__dict__: + cls.meta = PluginMeta( + name=_derive_plugin_name(cls.__name__), + version=_DEFAULT_PLUGIN_VERSION, + ) class Config(BaseModel): """Default empty configuration. Subclasses override to declare fields.""" @@ -434,6 +474,28 @@ class Plugin: """Return component providers.""" return [] + def capabilities(self) -> dict[str, Any]: + """Return a partial `ServerCapabilities` dict to merge into the server's capabilities. + + The returned dict follows the MCP `ServerCapabilities` shape. + Contributions from all plugins are deep-merged in registration + order, then applied on top of the server's built-in capabilities. + Later plugins can add to or override earlier plugins' entries; + this is intentional — plugin order is a user-facing configuration + knob, same as middleware order. + + A plugin advertising an experimental protocol extension: + + ```python + def capabilities(self): + return {"experimental": {"my/ext": {}}} + ``` + + A plugin modifying a built-in capability field follows the same + shape, keyed by the `ServerCapabilities` field name. + """ + return {} + def routes(self) -> list[BaseRoute]: """Return custom HTTP routes to mount on the server's ASGI app. diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 3731b0f92..7ec2f57f7 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -84,6 +84,7 @@ from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting from fastmcp.tools.base import Tool, ToolResult from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool_transform import ToolTransformConfig +from fastmcp.utilities.collections import deep_merge from fastmcp.utilities.components import FastMCPComponent, _coerce_version from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import FastMCPBaseModel, NotSet, NotSetT @@ -688,6 +689,29 @@ class FastMCP( p for p in self.plugins if not getattr(p, "_fastmcp_ephemeral", False) ] + def _apply_plugin_capabilities( + self, capabilities: mcp.types.ServerCapabilities + ) -> mcp.types.ServerCapabilities: + """Deep-merge plugin capability contributions into the server's capabilities. + + Called by `LowLevelServer.get_capabilities` after the base SDK + capabilities and FastMCP post-processing have been applied. + Each plugin's `capabilities()` dict is folded into the running + capabilities in registration order, with later plugins overriding + earlier ones at matching leaf keys — same semantics as dict + update, applied recursively. Plugins that return an empty dict + contribute nothing. + """ + contributions = [plugin.capabilities() for plugin in self.plugins] + if not any(contributions): + return capabilities + + merged = capabilities.model_dump(exclude_none=True) + for contribution in contributions: + if contribution: + deep_merge(merged, contribution) + return type(capabilities).model_validate(merged) + def add_provider(self, provider: Provider, *, namespace: str = "") -> None: """Add a provider for dynamic tools, resources, and prompts. diff --git a/src/fastmcp/utilities/collections.py b/src/fastmcp/utilities/collections.py new file mode 100644 index 000000000..68d970522 --- /dev/null +++ b/src/fastmcp/utilities/collections.py @@ -0,0 +1,26 @@ +"""Generic helpers for collection types (dicts, lists, etc.).""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +def deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]: + """Recursively merge `update` into `base` in place and return it. + + Dict values are merged recursively; other values (including `None` + and primitives) overwrite. Lists are not concatenated — `update`'s + list replaces `base`'s list. + + Values copied from `update` are deep-copied at assignment time so + that subsequent merges into `base` never mutate data owned by the + caller (e.g. a plugin returning a class-level dict from a hook). + """ + for key, value in update.items(): + existing = base.get(key) + if isinstance(existing, dict) and isinstance(value, dict): + deep_merge(existing, value) + else: + base[key] = deepcopy(value) + return base diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index f70598bd7..44384cb33 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -38,6 +38,12 @@ class _Recorder: self.events: list[tuple[str, str]] = [] +class _TestPlugin(Plugin): + """Base for test plugins. Relies on Plugin's auto-derived meta — + subclasses override `meta` only when a test asserts on a specific + name or version.""" + + class TestPluginMeta: """PluginMeta is the source-of-truth metadata model.""" @@ -242,12 +248,36 @@ class TestFromPackage: class TestPluginConstruction: """Plugin construction validates meta and config at instantiation time.""" - def test_plugin_without_meta_raises(self): - class NoMeta(Plugin): + def test_plugin_without_meta_auto_derives_from_class_name(self): + class ChannelPlugin(Plugin): pass - with pytest.raises(TypeError, match="meta"): - NoMeta() + p = ChannelPlugin() + # Class name is kebab-cased and the trailing "Plugin" suffix stripped. + assert p.meta.name == "channel" + assert p.meta.version == "0.1.0" + + def test_plugin_meta_auto_derivation_handles_acronyms(self): + class PIIRedactor(Plugin): + pass + + class CodeMode(Plugin): + pass + + class HTTPServerPlugin(Plugin): + pass + + assert PIIRedactor.meta.name == "pii-redactor" + assert CodeMode.meta.name == "code-mode" + # Trailing "-plugin" stripped, internal acronym preserved. + assert HTTPServerPlugin.meta.name == "http-server" + + def test_explicit_meta_is_not_overridden(self): + class P(Plugin): + meta = PluginMeta(name="custom", version="2.0.0") + + assert P.meta.name == "custom" + assert P.meta.version == "2.0.0" def test_plugin_with_default_config(self): class P(Plugin): @@ -1246,3 +1276,160 @@ class TestManifest: with pytest.raises(PluginError, match="fastmcp"): FastmcpInDeps.manifest() + + +class TestPluginCapabilities: + """Plugins contribute partial ServerCapabilities dicts via `capabilities()`.""" + + def test_default_returns_empty(self): + """Plugin with no override contributes nothing.""" + assert _TestPlugin().capabilities() == {} + + async def test_experimental_contribution_reaches_initialize_response(self): + """An experimental capability entry flows through to the client.""" + + class P(_TestPlugin): + def capabilities(self): + return {"experimental": {"my/ext": {}}} + + mcp = FastMCP("t", plugins=[P()]) + + async with Client(mcp) as c: + result = c.initialize_result + assert result is not None + experimental = result.capabilities.experimental or {} + assert experimental.get("my/ext") == {} + + async def test_multiple_plugins_merge_into_same_field(self): + """Contributions to the same top-level field are deep-merged.""" + + class A(_TestPlugin): + def capabilities(self): + return {"experimental": {"alpha": {"version": 1}}} + + class B(_TestPlugin): + def capabilities(self): + return {"experimental": {"beta": {}}} + + mcp = FastMCP("t", plugins=[A(), B()]) + + async with Client(mcp) as c: + result = c.initialize_result + assert result is not None + experimental = result.capabilities.experimental or {} + assert experimental.get("alpha") == {"version": 1} + assert experimental.get("beta") == {} + + async def test_later_plugin_overrides_earlier_on_same_key(self): + """Plugins run in sequence; later contributions override earlier ones. + + Plugin order is a user-facing configuration knob — same as + middleware order — so overriding a built-in or earlier plugin's + capability is intentional, not an error. + """ + + class Earlier(_TestPlugin): + def capabilities(self): + return {"experimental": {"shared": {"owner": "earlier"}}} + + class Later(_TestPlugin): + def capabilities(self): + return {"experimental": {"shared": {"owner": "later"}}} + + mcp = FastMCP("t", plugins=[Earlier(), Later()]) + + 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": "later"} + + async def test_plugin_can_add_non_experimental_field(self): + """Plugins can advertise top-level capability fields the server didn't set. + + `logging` is off by default on a FastMCP server; a plugin turning + it on must surface in the initialize response. + """ + + class P(_TestPlugin): + def capabilities(self): + return {"logging": {}} + + mcp = FastMCP("t", plugins=[P()]) + + async with Client(mcp) as c: + result = c.initialize_result + assert result is not None + assert result.capabilities.logging is not None + + async def test_plugin_can_override_built_in_subfield(self): + """Deep-merge applies to typed sub-fields of pre-populated capability objects. + + FastMCP already advertises `tools.listChanged=True` by default; a + plugin flipping it to `False` exercises the merge path through a + pydantic sub-model (not just the experimental dict). + """ + + class P(_TestPlugin): + def capabilities(self): + return {"tools": {"listChanged": False}} + + mcp = FastMCP("t", plugins=[P()]) + + async with Client(mcp) as c: + result = c.initialize_result + assert result is not None + assert result.capabilities.tools is not None + assert result.capabilities.tools.listChanged is False + + async def test_plugin_owned_capability_dict_is_not_mutated_across_plugins(self): + """Plugin-returned dicts must not be mutated by the merge. + + A plugin that returns a cached/class-level dict from + `capabilities()` gets the same object back on subsequent calls. + If the merge wrote that dict into `merged` by reference, a later + plugin's contribution would add keys to the earlier plugin's + dict, leaking state across initializations. + """ + + class A(_TestPlugin): + _caps = {"experimental": {"alpha": {}}} + + def capabilities(self): + return self._caps + + class B(_TestPlugin): + def capabilities(self): + return {"experimental": {"beta": {}}} + + a = A() + mcp = FastMCP("t", plugins=[a, B()]) + + async with Client(mcp) as c: + result = c.initialize_result + assert result is not None + experimental = result.capabilities.experimental or {} + assert "alpha" in experimental + assert "beta" in experimental + + # 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.""" + + class Loaded(_TestPlugin): + def capabilities(self): + return {"experimental": {"loaded": {}}} + + class Loader(_TestPlugin): + async def setup(self, server): + server.add_plugin(Loaded()) + + mcp = FastMCP("t", plugins=[Loader()]) + + async with Client(mcp) as c: + result = c.initialize_result + assert result is not None + experimental = result.capabilities.experimental or {} + assert experimental.get("loaded") == {} From cc290b3a2e182608d8b8b2d83f70ab7deb26eda9 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:39:58 -0400 Subject: [PATCH 06/17] Make Plugin generic over its Config model (#3983) --- src/fastmcp/server/plugins/base.py | 218 +++++++++++++++++++++++------ tests/server/test_plugins.py | 185 +++++++++++++++++++++--- 2 files changed, 336 insertions(+), 67 deletions(-) diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py index 200498a62..540bae4f5 100644 --- a/src/fastmcp/server/plugins/base.py +++ b/src/fastmcp/server/plugins/base.py @@ -2,8 +2,9 @@ Plugins package server-side behavior — middleware, component transforms, providers, and custom HTTP routes — into reusable, configurable, -distributable units. A plugin is a subclass of `Plugin` with a -class-level `PluginMeta` and an optional nested `Config` model. +distributable units. A plugin is a subclass of `Plugin` (optionally +parameterized with a pydantic config model — `Plugin[MyConfig]` — for +typed configuration). See the design document for the full specification. """ @@ -17,7 +18,16 @@ from contextlib import asynccontextmanager from email.message import Message as EmailMessage from importlib import metadata as importlib_metadata from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, cast +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Generic, + TypeVar, + cast, + get_args, + get_origin, +) from packaging.requirements import InvalidRequirement, Requirement from packaging.specifiers import InvalidSpecifier, SpecifierSet @@ -243,6 +253,20 @@ class PluginMeta(BaseModel): _DEFAULT_PLUGIN_VERSION = "0.1.0" +class _EmptyConfig(BaseModel): + """Default config for plugins that don't declare their own via the + `Plugin[ConfigType]` generic parameter.""" + + model_config = ConfigDict(extra="forbid") + + +C = TypeVar("C", bound=BaseModel) +"""Type variable for a plugin's config model. Bound to `BaseModel` so +any pydantic model is valid. Plugins without a config omit the generic +parameter; the runtime falls back to `_EmptyConfig` in that case. +""" + + def _derive_plugin_name(cls_name: str) -> str: """Kebab-case a class name, stripping a trailing ``Plugin`` suffix. @@ -259,7 +283,67 @@ def _derive_plugin_name(cls_name: str) -> str: return name -class Plugin: +def _resolve_plugin_config_cls(cls: type) -> type[BaseModel] | None: + """Resolve the config class bound to `Plugin[C]` for a subclass. + + Walks `cls.__orig_bases__`, recursing through intermediate `Plugin` + subclasses and propagating TypeVar substitutions. Returns the bound + `BaseModel` subclass, or `None` if the binding is still a TypeVar + (unresolved — typically an intermediate abstract base). + + Raises `TypeError` if a resolved argument is concrete but not a + `BaseModel` subclass (a misuse of `Plugin[NonPydanticType]`). + """ + + def _resolve(base: Any, substitutions: dict[Any, Any]) -> Any: + origin = get_origin(base) + if origin is None or not ( + isinstance(origin, type) and issubclass(origin, Plugin) + ): + return None + args = get_args(base) + # Apply outer-scope substitutions so a parent's TypeVar bound to + # a concrete type at this level becomes that concrete type here. + resolved_args = tuple(substitutions.get(a, a) for a in args) + + if origin is Plugin: + # We're at the root parameterization. + if not resolved_args: + return None + cfg = resolved_args[0] + # Still a TypeVar: unresolved at this level of the chain. + if isinstance(cfg, TypeVar): + return None + return cfg + + # Intermediate Plugin subclass. Push down its own TypeVar + # substitutions (from its `__parameters__`) and recurse into its + # bases to find the Plugin parameterization. + origin_params = getattr(origin, "__parameters__", ()) + new_subs = { + **substitutions, + **dict(zip(origin_params, resolved_args, strict=False)), + } + for inner in getattr(origin, "__orig_bases__", ()): + found = _resolve(inner, new_subs) + if found is not None: + return found + return None + + for base in getattr(cls, "__orig_bases__", ()): + resolved = _resolve(base, substitutions={}) + if resolved is None: + continue + if not (isinstance(resolved, type) and issubclass(resolved, BaseModel)): + raise TypeError( + f"{cls.__name__}: Plugin[...] generic parameter must be a " + f"pydantic BaseModel subclass, got {resolved!r}" + ) + return resolved + return None + + +class Plugin(Generic[C]): """Base class for FastMCP plugins. Subclass to define a plugin. A subclass may optionally declare a @@ -267,31 +351,29 @@ class Plugin: a default is derived from the class name (kebab-cased, trailing `Plugin` stripped) with version `0.1.0`. Declare `meta` explicitly when publishing or when Horizon/registry-facing metadata matters. - Subclasses may also declare a nested `Config` (subclass of - `pydantic.BaseModel`) describing configuration, and override any of - the lifecycle and contribution hooks. + + **Config typing.** Parameterize `Plugin` with a pydantic model to + give your plugin typed configuration — `self.config.` is then + correctly typed in editors and type checkers, and passing a dict or + model instance to the constructor validates against the model. + Plugins without a config omit the parameter. Example: ```python - from fastmcp.server.plugins import Plugin, PluginMeta from pydantic import BaseModel + from fastmcp.server.plugins import Plugin, PluginMeta - class PIIRedactor(Plugin): - meta = PluginMeta( - name="pii-redactor", - version="0.3.0", - dependencies=[ - "fastmcp-plugin-pii>=0.3.0", - "regex>=2024.0", - ], - ) + class PIIRedactorConfig(BaseModel): + patterns: list[str] = ["ssn", "email"] - class Config(BaseModel): - patterns: list[str] = ["ssn", "email"] + + class PIIRedactor(Plugin[PIIRedactorConfig]): + meta = PluginMeta(name="pii-redactor", version="0.3.0") def middleware(self): - return [PIIMiddleware(self.config)] + # self.config is typed as PIIRedactorConfig + return [PIIMiddleware(self.config.patterns)] ``` """ @@ -303,6 +385,16 @@ class Plugin: Horizon/registry-facing metadata matters. """ + _config_cls: ClassVar[type[BaseModel]] = _EmptyConfig + """Config model class resolved from the `Plugin[C]` generic parameter. + Auto-populated by `__init_subclass__`; falls back to `_EmptyConfig` + for plugins that don't parameterize `Plugin`. + """ + + config: C + """The validated config instance. Typed as `C`, the generic + parameter, so `self.config.` type-checks correctly.""" + def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) # Auto-derive meta if the subclass didn't declare its own. We @@ -314,13 +406,17 @@ class Plugin: name=_derive_plugin_name(cls.__name__), version=_DEFAULT_PLUGIN_VERSION, ) - - class Config(BaseModel): - """Default empty configuration. Subclasses override to declare fields.""" - - model_config = ConfigDict(extra="forbid") - - config: BaseModel + # Resolve the Config model from the generic parameter. We walk the + # `__orig_bases__` chain and propagate TypeVar substitutions, so + # both direct parameterization (`class P(Plugin[Cfg])`) and + # deferred binding (`class Abstract(Plugin[_T])` → + # `class P(Abstract[Cfg])`) resolve correctly. Intermediate + # generic bases with their own unrelated TypeVars are unaffected + # because we substitute through each step rather than treating + # `args[0]` as the config unconditionally. + config_cls = _resolve_plugin_config_cls(cls) + if config_cls is not None: + cls._config_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 @@ -329,12 +425,7 @@ class Plugin: # across lifespan cycles. _fastmcp_ephemeral: bool = False - def __init__(self, config: BaseModel | dict[str, Any] | None = None) -> None: - # A subclass's nested Config is a distinct class from Plugin.Config; - # we accept any BaseModel instance here and validate at runtime that - # it's (or coerces to) the subclass's own Config type. This is why - # `config` is typed as BaseModel rather than the nested Config — the - # nested declaration does not imply subclass relationship. + def __init__(self, config: C | dict[str, Any] | None = None) -> None: meta = getattr(type(self), "meta", None) if not isinstance(meta, PluginMeta): raise TypeError( @@ -343,24 +434,54 @@ class Plugin: ) self._validate_meta(meta) - config_cls = type(self).Config + config_cls = type(self)._config_cls + + def _wrap(exc: ValidationError) -> PluginConfigError: + # For unparameterized plugins, pydantic's error string + # includes "1 validation error for _EmptyConfig" — an + # internal class name users shouldn't see. Emit a scoped + # message instead; for parameterized plugins, forward + # pydantic's full diagnostic. + if config_cls is _EmptyConfig: + keys = list(config.keys()) if isinstance(config, dict) else [] + return PluginConfigError( + f"Invalid configuration for {type(self).__name__}: this " + f"plugin declares no config fields but received " + f"{keys}." + ) + return PluginConfigError( + f"Invalid configuration for {type(self).__name__}: {exc}" + ) + if config is None: - value: BaseModel = config_cls() + try: + value: BaseModel = config_cls() + except ValidationError as exc: + # Required config fields with no default: surface the + # failure as PluginConfigError so callers that catch + # the documented exception type behave consistently + # with the dict path below. + raise _wrap(exc) from exc elif isinstance(config, config_cls): value = config elif isinstance(config, dict): try: value = config_cls(**config) except ValidationError as exc: - raise PluginConfigError( - f"Invalid configuration for {type(self).__name__}: {exc}" - ) from exc + raise _wrap(exc) from exc else: - raise PluginConfigError( - f"Config for {type(self).__name__} must be a {config_cls.__name__} " - f"instance or dict, not {type(config).__name__}" + # `_EmptyConfig` is an internal implementation detail for + # unparameterized plugins. Don't leak its name to authors. + expected = ( + "dict" + if config_cls is _EmptyConfig + else f"{config_cls.__name__} instance or dict" ) - self.config = value + raise PluginConfigError( + f"Config for {type(self).__name__} must be a {expected}, " + f"not {type(config).__name__}" + ) + self.config = cast(C, value) # -- validation ----------------------------------------------------------- @@ -542,11 +663,20 @@ class Plugin: # have produced from a live plugin instance. cls._validate_meta(meta) - config_cls = getattr(cls, "Config", Plugin.Config) + config_cls = cls._config_cls + config_schema = config_cls.model_json_schema() + # `_EmptyConfig` is an internal implementation detail; don't + # leak its name or docstring into the published manifest JSON + # consumed by Horizon, registries, and CI tooling. Pydantic v2 + # emits both `title` (from `__name__`) and `description` (from + # the class docstring) in `model_json_schema()`; strip both. + if config_cls is _EmptyConfig: + config_schema.pop("title", None) + config_schema.pop("description", None) data: dict[str, Any] = { "manifest_version": 1, **meta.model_dump(), - "config_schema": config_cls.model_json_schema(), + "config_schema": config_schema, "entry_point": f"{cls.__module__}:{cls.__qualname__}", } diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index 44384cb33..34ae5d1bd 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -8,6 +8,7 @@ from contextlib import suppress from importlib import metadata as importlib_metadata from importlib.metadata import version as dist_version from pathlib import Path +from typing import Generic, TypeVar import pytest from packaging.version import Version @@ -280,50 +281,170 @@ class TestPluginConstruction: assert P.meta.version == "2.0.0" def test_plugin_with_default_config(self): + """A Plugin without a generic parameter gets an empty default config.""" + class P(Plugin): meta = PluginMeta(name="p", version="0.1.0") p = P() - assert isinstance(p.config, Plugin.Config) + assert isinstance(p.config, BaseModel) + # No fields to inspect — the point is that construction works with None. def test_config_accepts_instance(self): - class P(Plugin): + class PConfig(BaseModel): + who: str = "world" + + class P(Plugin[PConfig]): meta = PluginMeta(name="p", version="0.1.0") - class Config(BaseModel): - who: str = "world" - - p = P(config=P.Config(who="jeremiah")) - assert isinstance(p.config, P.Config) + p = P(PConfig(who="jeremiah")) + assert isinstance(p.config, PConfig) assert p.config.who == "jeremiah" def test_config_accepts_dict(self): - class P(Plugin): + class PConfig(BaseModel): + who: str = "world" + + class P(Plugin[PConfig]): meta = PluginMeta(name="p", version="0.1.0") - class Config(BaseModel): - who: str = "world" - - p = P(config={"who": "jeremiah"}) - assert isinstance(p.config, P.Config) + p = P({"who": "jeremiah"}) + assert isinstance(p.config, PConfig) assert p.config.who == "jeremiah" - def test_invalid_config_raises_plugin_config_error(self): + def test_generic_parameter_binds_config_cls(self): + """`Plugin[ConfigType]` stashes the Config on the subclass so dict + validation, manifest generation, and runtime introspection all use + the author-declared model.""" + + class PConfig(BaseModel): + who: str = "world" + + class P(Plugin[PConfig]): + meta = PluginMeta(name="p", version="0.1.0") + + assert P._config_cls is PConfig + + def test_unparameterized_plugin_uses_empty_default_config(self): + """A Plugin without a generic parameter gets an empty default that + rejects unknown keys (extra='forbid').""" + class P(Plugin): meta = PluginMeta(name="p", version="0.1.0") - class Config(BaseModel): - count: int + # No-arg construction works. + P() + # Unknown config keys are rejected by the empty default. + with pytest.raises(PluginConfigError) as exc_info: + P({"who": "jeremiah"}) + # The error message must not leak the `_EmptyConfig` implementation + # class name; users shouldn't see private framework detail. + assert "_EmptyConfig" not in str(exc_info.value) + assert "no config fields" in str(exc_info.value) - with pytest.raises(PluginConfigError): - P(config={"count": "not a number"}) + def test_invalid_config_raises_plugin_config_error(self): + """Wrong-typed value for a declared field wraps ValidationError + into PluginConfigError — exercising the generic Plugin[C] path.""" + + class PConfig(BaseModel): + count: int + + class P(Plugin[PConfig]): + meta = PluginMeta(name="p", version="0.1.0") + + with pytest.raises(PluginConfigError, match="count"): + P({"count": "not a number"}) + + def test_required_field_missing_raises_plugin_config_error_on_no_args(self): + """Required config field with no default must surface as + PluginConfigError (not a raw pydantic.ValidationError) when the + plugin is constructed with no arguments.""" + + class PConfig(BaseModel): + api_key: str # required, no default + + class P(Plugin[PConfig]): + meta = PluginMeta(name="p", version="0.1.0") + + with pytest.raises(PluginConfigError, match="api_key"): + P() def test_bad_config_type_raises(self): class P(Plugin): meta = PluginMeta(name="p", version="0.1.0") with pytest.raises(PluginConfigError): - P(config="not a config") # ty: ignore[invalid-argument-type] + P("not a config") # type: ignore[arg-type] + + def test_non_basemodel_generic_arg_raises_at_class_creation(self): + """`Plugin[T]` where T is not a pydantic BaseModel must fail loudly. + + The `# ty: ignore` tells the static checker that violating the type + bound is intentional here — we're exercising the *runtime* guard. + """ + + class NotAModel: + pass + + with pytest.raises(TypeError, match="BaseModel subclass"): + + class _Bad(Plugin[NotAModel]): # ty: ignore[invalid-type-arguments] + meta = PluginMeta(name="bad", version="0.1.0") + + def test_intermediate_generic_subclass_parameterization_is_not_misread_as_config( + self, + ): + """A concrete subclass of an intermediate Plugin base with its + own generic parameter must not have its generic arg misread as + the plugin's config type. + + Given `class Intermediate(Plugin[Cfg], Generic[T])` and + `class Concrete(Intermediate[int])`, `int` is the intermediate's + own TypeVar substitution, NOT the plugin config. `Concrete` + should inherit `Cfg` through the intermediate, not raise because + `int` isn't a `BaseModel`. + """ + _T = TypeVar("_T") + + class Cfg(BaseModel): + value: int = 0 + + class Intermediate(Plugin[Cfg], Generic[_T]): + meta = PluginMeta(name="intermediate", version="0.1.0") + + class Concrete(Intermediate[int]): + meta = PluginMeta(name="concrete", version="0.1.0") + + assert Intermediate._config_cls is Cfg + assert Concrete._config_cls is Cfg + assert isinstance(Concrete().config, Cfg) + + def test_deferred_config_binding_resolves_in_concrete_subclass(self): + """Abstract plugin bases declare `Plugin[_T]` with an unbound + TypeVar; concrete subclasses bind `_T` via `AbstractBase[Cfg]`. + The resolver must propagate the substitution through the chain. + """ + _T = TypeVar("_T", bound=BaseModel) + + class MyConfig(BaseModel): + api_key: str = "default" + + class AbstractPlugin(Plugin[_T]): + meta = PluginMeta(name="abstract", version="0.1.0") + + class ConcretePlugin(AbstractPlugin[MyConfig]): + meta = PluginMeta(name="concrete", version="0.1.0") + + # Abstract base can't resolve (TypeVar still unbound). + assert AbstractPlugin._config_cls is not MyConfig + # Concrete leaf resolves through the intermediate. + assert ConcretePlugin._config_cls is MyConfig + assert isinstance(ConcretePlugin().config, MyConfig) + assert ConcretePlugin({"api_key": "secret"}).config.api_key == "secret" + # Manifest reflects the concrete config, not the empty default. + m = ConcretePlugin.manifest() + assert m is not None + assert "api_key" in m["config_schema"]["properties"] class TestPluginValidation: @@ -1189,7 +1310,10 @@ class TestManifest: """manifest() produces a JSON-serializable dict and can write to disk.""" def test_manifest_shape(self): - class P(Plugin): + class PConfig(BaseModel): + who: str = "world" + + class P(Plugin[PConfig]): meta = PluginMeta( name="p", version="0.1.0", @@ -1200,9 +1324,6 @@ class TestManifest: meta={"owning_team": "platform"}, ) - class Config(BaseModel): - who: str = "world" - m = P.manifest() assert m is not None assert m["manifest_version"] == 1 @@ -1218,6 +1339,24 @@ class TestManifest: assert m["config_schema"]["type"] == "object" assert "who" in m["config_schema"]["properties"] + def test_manifest_omits_empty_config_internal_name_and_docstring(self): + """For plugins without a Config, the manifest's `config_schema` + must not leak `_EmptyConfig` — neither as `title` nor as + `description` (pydantic emits both by default).""" + + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + m = P.manifest() + assert m is not None + schema = m["config_schema"] + assert "_EmptyConfig" not in schema.get("title", "") + # Pydantic v2 emits the class docstring as `description`; strip it too. + assert ( + "description" not in schema or "_EmptyConfig" not in schema["description"] + ) + assert "Plugin[ConfigType]" not in schema.get("description", "") + def test_manifest_custom_fields_subclass(self): class AcmeMeta(PluginMeta): owning_team: str From 67f226d453e54335725db432b5eab81bafacd9cf Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:00:00 -0400 Subject: [PATCH 07/17] Enforce JSON-serializable contract on Plugin Config (#3986) --- src/fastmcp/server/plugins/base.py | 93 ++++++++++ tests/server/test_plugins.py | 289 +++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+) diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py index 540bae4f5..20dcff97d 100644 --- a/src/fastmcp/server/plugins/base.py +++ b/src/fastmcp/server/plugins/base.py @@ -417,6 +417,13 @@ class Plugin(Generic[C]): config_cls = _resolve_plugin_config_cls(cls) if config_cls is not None: cls._config_cls = config_cls + # Enforce the JSON-serializable contract on the resolved config. + # Every plugin config must round-trip through JSON so plugins + # can be loaded from config files, rendered by registry/Horizon + # forms, and published to manifest artifacts. Runs on every + # Plugin subclass — including `_EmptyConfig`, which passes + # 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 @@ -485,6 +492,88 @@ class Plugin(Generic[C]): # -- validation ----------------------------------------------------------- + @staticmethod + def _validate_config_cls(config_cls: type[BaseModel]) -> None: + """Ensure a plugin's config model is fully JSON-serializable. + + Plugin configs are the distribution surface — they're loaded + from JSON/YAML, rendered into Horizon/registry forms, and + published in manifests. They must round-trip through JSON + without loss. Enforced at class creation so authoring mistakes + fail loudly at import time rather than at `fastmcp plugin + manifest` / registry-render time. + + To expose callable-ish behavior through config, plugin authors + should surface a string-keyed enum (e.g. + `mode: Literal["json", "markdown"] = "json"`) and resolve to + the real callable internally. Runtime Python extensibility — + custom callables, connection pools, etc. — belongs on the + plugin's `__init__` signature, not the Config model. + + Two checks: (1) `model_json_schema()` must succeed — catches + fields pydantic can't describe in JSON at all (raw callables, + classes without pydantic hooks). (2) If the config can be + built without arguments (all fields have defaults), exercise + the runtime serialization path via `model_dump(mode="json")` + — catches the "partial-hooks" case where a type has + `__get_pydantic_json_schema__` but no matching serializer + (schema generation alone would silently pass). + + Configs with required fields skip the dump check at class + creation — we can't construct an instance without a value. + Partial-hooks violations on those fields surface on first + `Config(**data).model_dump(mode="json")`. Configs with + unresolved forward references skip the entire check; it + re-runs at manifest time once the model is complete. + """ + if not getattr(config_cls, "__pydantic_complete__", True): + return + + try: + config_cls.model_json_schema() + except Exception as exc: + raise PluginError( + f"Plugin config {config_cls.__name__} is not JSON-" + f"serializable: {exc}. Every field must be expressible " + f"in JSON. Callable fields and raw Python classes without " + f"pydantic serialization hooks are not supported." + ) from exc + + # If the config builds without args, exercise the real + # serialization path to catch types that have a schema hook + # but no serializer. + try: + instance = config_cls() + except ValidationError as exc: + # A ValidationError here can mean two things: (1) required + # fields without defaults — can't build without user + # input, expected, skip the dump test; or (2) a default + # value failed a field validator, which is a real authoring + # bug and should surface as PluginError at class creation. + if all(err.get("type") == "missing" for err in exc.errors()): + return + raise PluginError( + f"Plugin config {config_cls.__name__} has an invalid " + f"default value: {exc}" + ) from exc + except Exception as exc: + # Non-ValidationError failures (TypeError from a broken + # default_factory, RuntimeError from model_post_init, etc.) + # are also author-side bugs — wrap so the error carries + # plugin attribution rather than propagating bare. + raise PluginError( + f"Plugin config {config_cls.__name__} could not be " + f"instantiated with defaults: {exc}" + ) from exc + try: + instance.model_dump(mode="json") + except Exception as exc: + raise PluginError( + f"Plugin config {config_cls.__name__} cannot be " + f"serialized to JSON at runtime: {exc}. Every field " + f"must have both a JSON schema and a JSON serializer." + ) from exc + @staticmethod def _validate_meta(meta: PluginMeta) -> None: """Check that the plugin's declared metadata is internally consistent.""" @@ -664,6 +753,10 @@ class Plugin(Generic[C]): cls._validate_meta(meta) config_cls = cls._config_cls + # Re-run the JSON-serializable check here. Plugins with forward- + # reference configs skip validation at class creation, so manifest + # emission is the publish-time boundary that has to enforce it. + cls._validate_config_cls(config_cls) config_schema = config_cls.model_json_schema() # `_EmptyConfig` is an internal implementation detail; don't # leak its name or docstring into the published manifest JSON diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index 34ae5d1bd..2a1c2afc5 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -10,6 +10,7 @@ from importlib.metadata import version as dist_version from pathlib import Path from typing import Generic, TypeVar +import pydantic import pytest from packaging.version import Version from pydantic import BaseModel, ValidationError @@ -501,6 +502,294 @@ class TestPluginValidation: Incompat().check_fastmcp_compatibility() +class TestConfigJsonSerializable: + """Plugin configs must be JSON-serializable — a hard rule. Configs + are loaded from JSON/YAML, rendered into Horizon/registry forms, + and published in manifests; any field that can't round-trip through + JSON breaks the distribution story.""" + + def test_arbitrary_type_without_pydantic_hooks_rejected(self): + """A raw Python class with no pydantic hooks can't be described + in JSON — `model_json_schema()` fails and we surface the + failure as PluginError.""" + + class Arbitrary: + pass + + class BadConfig(BaseModel): + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + thing: Arbitrary = Arbitrary() + + with pytest.raises(PluginError, match="not JSON"): + + class Bad(Plugin[BadConfig]): + meta = PluginMeta(name="bad", version="0.1.0") + + def test_arbitrary_type_with_pydantic_hooks_accepted(self): + """A custom type with `__get_pydantic_core_schema__` and a + JSON-safe serializer IS JSON-round-trippable, even alongside + `arbitrary_types_allowed=True`. Plugin authors can bring their + own types as long as they provide the hooks.""" + from pydantic_core import core_schema + + class JsonSafe: + def __init__(self, value: str): + self.value = value + + @classmethod + def __get_pydantic_core_schema__(cls, source, handler): + return core_schema.no_info_after_validator_function( + cls, + handler(str), + serialization=core_schema.plain_serializer_function_ser_schema( + lambda v: v.value, return_schema=core_schema.str_schema() + ), + ) + + class GoodConfig(BaseModel): + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + thing: JsonSafe = JsonSafe("hi") + + class Good(Plugin[GoodConfig]): + meta = PluginMeta(name="good", version="0.1.0") + + # The config dumps cleanly to JSON because of the plugin- + # author-provided hooks. + assert Good().config.model_dump(mode="json") == {"thing": "hi"} + + def test_callable_field_rejected(self): + """Callable fields can't be round-tripped through JSON — reject.""" + from collections.abc import Callable + + class BadConfig(BaseModel): + handler: Callable[[str], str] + + with pytest.raises(PluginError, match="not JSON"): + + class Bad(Plugin[BadConfig]): + meta = PluginMeta(name="bad", version="0.1.0") + + @pytest.mark.parametrize( + "field_type, default", + [ + (pydantic.SecretStr, pydantic.SecretStr("s3cret")), + (int, 42), + (str, "hello"), + (list[str], ["a"]), + ], + ) + def test_common_json_serializable_fields_accepted(self, field_type, default): + """Common pydantic-supported types round-trip through JSON and + must pass validation.""" + + class GoodConfig(BaseModel): + value: field_type = default # type: ignore[valid-type] + + class Good(Plugin[GoodConfig]): + meta = PluginMeta(name="good", version="0.1.0") + + # Construction and config access both work. + plugin = Good() + assert plugin.config.value == default + + def test_nested_basemodel_field_accepted(self): + """Nested pydantic models are fully JSON-serializable.""" + + class Inner(BaseModel): + name: str = "x" + count: int = 0 + + class OuterConfig(BaseModel): + inner: Inner = Inner() + + class Outer(Plugin[OuterConfig]): + meta = PluginMeta(name="outer", version="0.1.0") + + assert Outer().config.inner.name == "x" + + def test_datetime_and_path_fields_accepted(self): + """datetime and Path are pydantic-supported JSON types.""" + from datetime import datetime + from pathlib import Path as PathlibPath + + class GoodConfig(BaseModel): + when: datetime = datetime(2026, 1, 1) + where: PathlibPath = PathlibPath("/tmp") + + class Good(Plugin[GoodConfig]): + meta = PluginMeta(name="good", version="0.1.0") + + assert isinstance(Good().config.when, datetime) + + def test_partial_hooks_without_serializer_rejected(self): + """A custom type with `__get_pydantic_json_schema__` but no + matching serializer would pass a schema-generation-only check + while failing at runtime `model_dump(mode='json')`. The + validator exercises the real serialization path when the + config is buildable without args, so the break surfaces at + class creation (for configs with defaults) rather than at + publish/serialize time.""" + from pydantic_core import core_schema + + class Tricky: + @classmethod + def __get_pydantic_core_schema__(cls, source, handler): + # Validator only — no serialization= argument. + return core_schema.no_info_plain_validator_function(lambda v: cls()) + + @classmethod + def __get_pydantic_json_schema__(cls, schema, handler): + return {"type": "string"} + + class PartialConfig(BaseModel): + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + x: Tricky = Tricky() + + with pytest.raises(PluginError, match="cannot be serialized"): + + class Bad(Plugin[PartialConfig]): + meta = PluginMeta(name="bad", version="0.1.0") + + def test_default_value_failing_validator_raises_plugin_error(self): + """A ValidationError from a field validator rejecting its own + default isn't the 'required field, skip' case — surface it as + PluginError at class creation rather than silently accepting a + class that can never be constructed without args.""" + from pydantic import field_validator + + class BadDefaultConfig(BaseModel): + model_config = pydantic.ConfigDict(validate_default=True) + x: int = -1 + + @field_validator("x") + @classmethod + def must_be_positive(cls, v: int) -> int: + if v <= 0: + raise ValueError("x must be positive") + return v + + with pytest.raises(PluginError, match="invalid default"): + + class Bad(Plugin[BadDefaultConfig]): + meta = PluginMeta(name="bad", version="0.1.0") + + def test_non_validation_exception_from_default_wrapped_as_plugin_error(self): + """Non-ValidationError exceptions during default-construction + (TypeError from a broken default_factory, etc.) are wrapped as + PluginError so the message carries plugin attribution.""" + from pydantic import Field + + def broken_factory() -> int: + raise TypeError("factory is busted") + + class BadFactoryConfig(BaseModel): + x: int = Field(default_factory=broken_factory) + + with pytest.raises(PluginError, match="could not be instantiated"): + + class Bad(Plugin[BadFactoryConfig]): + meta = PluginMeta(name="bad", version="0.1.0") + + def test_required_field_partial_hooks_not_caught_at_class_creation(self): + """Documented trade-off: a partial-hooks type on a required + field slips past class-creation validation because the dump + test only runs on a buildable instance. The violation surfaces + at first real instantiation / serialization instead.""" + from pydantic_core import core_schema + + class Tricky: + @classmethod + def __get_pydantic_core_schema__(cls, source, handler): + return core_schema.no_info_plain_validator_function(lambda v: cls()) + + @classmethod + def __get_pydantic_json_schema__(cls, schema, handler): + return {"type": "string"} + + class RequiredBadConfig(BaseModel): + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + x: Tricky # required, no default — class creation can't build + + # Plugin class is created without raising. Manifest also succeeds + # because the schema itself is emittable. + class Required(Plugin[RequiredBadConfig]): + meta = PluginMeta(name="req", version="0.1.0") + + m = Required.manifest() + assert m is not None + assert "x" in m["config_schema"]["properties"] + + def test_manifest_revalidates_rebuilt_forward_reference_config(self): + """A config with a forward reference skips validation at class + creation, then gets validated at manifest() time once the + reference is resolved and the model is rebuilt. If the rebuilt + config has a partial-hooks default that the dump check catches, + manifest() must raise.""" + from typing import Optional + + from pydantic_core import core_schema + + class Tricky: + @classmethod + def __get_pydantic_core_schema__(cls, source, handler): + return core_schema.no_info_plain_validator_function(lambda v: cls()) + + @classmethod + def __get_pydantic_json_schema__(cls, schema, handler): + return {"type": "string"} + + class UnfinishedConfig(BaseModel): + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) + child: Optional[Child] = None # noqa: F821, UP045 + broken: Tricky = Tricky() + + # Plugin class creation defers validation (forward ref unresolved). + class Deferred(Plugin[UnfinishedConfig]): + meta = PluginMeta(name="deferred", version="0.1.0") + + class Child(BaseModel): + pass + + UnfinishedConfig.model_rebuild() + + # manifest() re-runs _validate_config_cls against the + # now-complete model and catches the partial-hooks violation + # via the dump test. + with pytest.raises(PluginError, match="cannot be serialized"): + Deferred.manifest() + + def test_forward_reference_config_skips_validation(self): + """Configs with unresolved forward references can't be + schema-checked at class creation; validation skips so the + plugin class itself can be defined. The author is expected + to run the check later (manifest generation will trip any + real problems).""" + + class UnfinishedConfig(BaseModel): + child: NotYetDefined # noqa: F821 # ty: ignore[unresolved-reference] + + # This should NOT raise even though UnfinishedConfig isn't + # fully defined — validation defers until the model is + # rebuildable. + class P(Plugin[UnfinishedConfig]): + meta = PluginMeta(name="p", version="0.1.0") + + assert P._config_cls is UnfinishedConfig + + def test_empty_default_config_passes_validation(self): + """The framework's internal `_EmptyConfig` must pass its own + JSON-serializable check (regression: it's used as the fallback + for every unparameterized plugin).""" + + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + # If _EmptyConfig failed validation, class creation above would + # have raised. This test is explicit documentation of the + # requirement. + assert P().config is not None + + class TestRegistration: """Plugins register before startup; add_plugin is a list append.""" From 34cb2218dc28ff7952be821989cf5b6ffd7ec6c6 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:40:25 -0400 Subject: [PATCH 08/17] Make PluginMeta.version optional; bundled plugins default to None (#3991) --- src/fastmcp/server/plugins/base.py | 20 ++++++++++---------- tests/server/test_plugins.py | 25 ++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py index 20dcff97d..938785d48 100644 --- a/src/fastmcp/server/plugins/base.py +++ b/src/fastmcp/server/plugins/base.py @@ -75,8 +75,14 @@ class PluginMeta(BaseModel): name: str """Plugin name. Required. Must be unique within a server.""" - version: str - """Plugin version (plugin's own semver, independent of fastmcp).""" + version: str | None = None + """Plugin's independent semver, if it has one. `None` means the + plugin is bundled with its containing package (typically fastmcp + itself) and doesn't track a separate release cadence — which is the + correct answer for first-party plugins that ship in-tree. Published + plugins derive this from their PyPI distribution via + `PluginMeta.from_package(...)`. + """ description: str | None = None """Short human-readable description.""" @@ -250,9 +256,6 @@ class PluginMeta(BaseModel): return cls(**derived) -_DEFAULT_PLUGIN_VERSION = "0.1.0" - - class _EmptyConfig(BaseModel): """Default config for plugins that don't declare their own via the `Plugin[ConfigType]` generic parameter.""" @@ -349,7 +352,7 @@ class Plugin(Generic[C]): Subclass to define a plugin. A subclass may optionally declare a class-level `meta` attribute (a `PluginMeta` instance); if omitted, a default is derived from the class name (kebab-cased, trailing - `Plugin` stripped) with version `0.1.0`. Declare `meta` explicitly + `Plugin` stripped) and no independent version. Declare `meta` explicitly when publishing or when Horizon/registry-facing metadata matters. **Config typing.** Parameterize `Plugin` with a pydantic model to @@ -402,10 +405,7 @@ class Plugin(Generic[C]): # meta from an intermediate subclass isn't treated as a local # declaration — each concrete Plugin class gets its own name. if "meta" not in cls.__dict__: - cls.meta = PluginMeta( - name=_derive_plugin_name(cls.__name__), - version=_DEFAULT_PLUGIN_VERSION, - ) + cls.meta = PluginMeta(name=_derive_plugin_name(cls.__name__)) # Resolve the Config model from the generic parameter. We walk the # `__orig_bases__` chain and propagate TypeVar substitutions, so # both direct parameterization (`class P(Plugin[Cfg])`) and diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index 2a1c2afc5..7687cc372 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -50,9 +50,11 @@ class TestPluginMeta: """PluginMeta is the source-of-truth metadata model.""" def test_required_fields(self): - meta = PluginMeta(name="x", version="0.1.0") + meta = PluginMeta(name="x") assert meta.name == "x" - assert meta.version == "0.1.0" + # `version` is optional — bundled plugins don't track a separate + # release cadence from their container. + assert meta.version is None assert meta.description is None assert meta.tags == [] assert meta.dependencies == [] @@ -78,6 +80,22 @@ class TestPluginMeta: meta = AcmeMeta(name="x", version="0.1.0", owning_team="platform") assert meta.owning_team == "platform" + def test_version_is_optional_and_defaults_to_none(self): + """Bundled plugins don't have an independent version; `None` is + the honest answer and avoids both lockstep lies (phantom bumps) + and sentinel strings like "bundled" that break semver consumers.""" + meta = PluginMeta(name="bundled") + assert meta.version is None + # Manifest emission keeps the field — consumers see `null` and + # can render "bundled" or similar at the presentation layer. + assert meta.model_dump()["version"] is None + + def test_explicit_version_still_accepted(self): + """Published plugins set a real semver, typically via + `PluginMeta.from_package(...)`; the field still accepts any string.""" + meta = PluginMeta(name="published", version="1.2.3") + assert meta.version == "1.2.3" + class TestFromPackage: """PluginMeta.from_package() derives metadata from importlib.metadata.""" @@ -257,7 +275,8 @@ class TestPluginConstruction: p = ChannelPlugin() # Class name is kebab-cased and the trailing "Plugin" suffix stripped. assert p.meta.name == "channel" - assert p.meta.version == "0.1.0" + # Bundled plugins have no independent version. + assert p.meta.version is None def test_plugin_meta_auto_derivation_handles_acronyms(self): class PIIRedactor(Plugin): From 3c0d2485264f1025889b0e6e6af6b402530ec1e9 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:44:41 -0400 Subject: [PATCH 09/17] Convert search transforms to the Search plugin (#3989) --- examples/search/README.md | 21 -- examples/tool_search/README.md | 28 ++ .../{search => tool_search}/client_bm25.py | 4 +- .../{search => tool_search}/client_regex.py | 4 +- .../{search => tool_search}/server_bm25.py | 18 +- .../{search => tool_search}/server_regex.py | 14 +- .../experimental/transforms/code_mode.py | 9 +- src/fastmcp/server/plugins/base.py | 11 +- .../server/plugins/tool_search/__init__.py | 18 ++ .../server/plugins/tool_search/base.py | 265 +++++++++++++++++ .../server/plugins/tool_search/bm25.py | 152 ++++++++++ .../server/plugins/tool_search/plugin.py | 89 ++++++ .../server/plugins/tool_search/regex.py | 55 ++++ .../server/transforms/search/__init__.py | 41 ++- src/fastmcp/server/transforms/search/base.py | 277 +----------------- src/fastmcp/server/transforms/search/bm25.py | 145 +-------- src/fastmcp/server/transforms/search/regex.py | 56 +--- .../test_code_mode_serialization.py | 2 +- tests/server/plugins/__init__.py | 0 tests/server/plugins/test_tool_search.py | 185 ++++++++++++ tests/server/transforms/test_search.py | 6 +- 21 files changed, 870 insertions(+), 530 deletions(-) delete mode 100644 examples/search/README.md create mode 100644 examples/tool_search/README.md rename examples/{search => tool_search}/client_bm25.py (97%) rename examples/{search => tool_search}/client_regex.py (97%) rename examples/{search => tool_search}/server_bm25.py (79%) rename examples/{search => tool_search}/server_regex.py (81%) create mode 100644 src/fastmcp/server/plugins/tool_search/__init__.py create mode 100644 src/fastmcp/server/plugins/tool_search/base.py create mode 100644 src/fastmcp/server/plugins/tool_search/bm25.py create mode 100644 src/fastmcp/server/plugins/tool_search/plugin.py create mode 100644 src/fastmcp/server/plugins/tool_search/regex.py create mode 100644 tests/server/plugins/__init__.py create mode 100644 tests/server/plugins/test_tool_search.py diff --git a/examples/search/README.md b/examples/search/README.md deleted file mode 100644 index 32a26390d..000000000 --- a/examples/search/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Search Transforms - -When a server exposes many tools, listing them all at once can overwhelm an LLM's context window. Search transforms collapse the full tool catalog behind a search interface — clients see only `search_tools` and `call_tool`, and discover the real tools on demand. - -## Two search strategies - -**Regex** (`RegexSearchTransform`) — clients search with regex patterns like `add|multiply` or `text.*`. Fast and precise when you know what you're looking for. - -**BM25** (`BM25SearchTransform`) — clients search with natural language like `"work with numbers"`. Results are ranked by relevance using BM25 scoring. The index rebuilds automatically when tools change. - -Both strategies respect the full auth pipeline: middleware, visibility transforms, and component-level auth checks all apply to search results. - -## Run - -```bash -# Regex -uv run python examples/search/client_regex.py - -# BM25 -uv run python examples/search/client_bm25.py -``` diff --git a/examples/tool_search/README.md b/examples/tool_search/README.md new file mode 100644 index 000000000..003bc526e --- /dev/null +++ b/examples/tool_search/README.md @@ -0,0 +1,28 @@ +# ToolSearch plugin + +When a server exposes many tools, listing them all at once can overwhelm an LLM's context window. The `ToolSearch` plugin collapses the full tool catalog behind a search interface — clients see only `search_tools` and `call_tool`, and discover the real tools on demand. + +## Two search strategies + +**Regex** (`strategy="regex"`) — clients search with regex patterns like `add|multiply` or `text.*`. Fast and precise when you know what you're looking for. + +**BM25** (`strategy="bm25"`) — clients search with natural language like `"work with numbers"`. Results are ranked by relevance using BM25 scoring. The index rebuilds automatically when tools change. + +Both strategies respect the full auth pipeline: middleware, visibility transforms, and component-level auth checks all apply to search results. + +```python +from fastmcp import FastMCP +from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig + +mcp = FastMCP("Server", plugins=[ToolSearch(ToolSearchConfig(strategy="regex"))]) +``` + +## Run + +```bash +# Regex +uv run python examples/tool_search/client_regex.py + +# BM25 +uv run python examples/tool_search/client_bm25.py +``` diff --git a/examples/search/client_bm25.py b/examples/tool_search/client_bm25.py similarity index 97% rename from examples/search/client_bm25.py rename to examples/tool_search/client_bm25.py index b33e395a3..451f21c54 100644 --- a/examples/search/client_bm25.py +++ b/examples/tool_search/client_bm25.py @@ -4,7 +4,7 @@ BM25 search accepts natural language queries instead of regex patterns. This client shows how relevance ranking surfaces the best matches. Run with: - uv run python examples/search/client_bm25.py + uv run python examples/tool_search/client_bm25.py """ import asyncio @@ -65,7 +65,7 @@ def _tool_table( async def main(): - async with Client("examples/search/server_bm25.py") as client: + async with Client("examples/tool_search/server_bm25.py") as client: console.print() console.rule("[bold]BM25 Search Transform[/bold]") console.print() diff --git a/examples/search/client_regex.py b/examples/tool_search/client_regex.py similarity index 97% rename from examples/search/client_regex.py rename to examples/tool_search/client_regex.py index ccccefbd2..cbed78596 100644 --- a/examples/search/client_regex.py +++ b/examples/tool_search/client_regex.py @@ -4,7 +4,7 @@ Regex search lets clients find tools by matching patterns against tool names and descriptions. Precise when you know what you're looking for. Run with: - uv run python examples/search/client_regex.py + uv run python examples/tool_search/client_regex.py """ import asyncio @@ -65,7 +65,7 @@ def _tool_table( async def main(): - async with Client("examples/search/server_regex.py") as client: + async with Client("examples/tool_search/server_regex.py") as client: console.print() console.rule("[bold]Regex Search Transform[/bold]") console.print() diff --git a/examples/search/server_bm25.py b/examples/tool_search/server_bm25.py similarity index 79% rename from examples/search/server_bm25.py rename to examples/tool_search/server_bm25.py index 63cdf8048..231e8ad8b 100644 --- a/examples/search/server_bm25.py +++ b/examples/tool_search/server_bm25.py @@ -9,15 +9,20 @@ The index is built lazily and rebuilt automatically when the tool catalog changes (e.g. tools added or removed between requests). Run with: - uv run python examples/search/server_bm25.py + uv run python examples/tool_search/server_bm25.py """ import os from fastmcp import FastMCP -from fastmcp.server.transforms.search import BM25SearchTransform +from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig -mcp = FastMCP("BM25 Search Demo") +mcp = FastMCP( + "BM25 Search Demo", + plugins=[ + ToolSearch(ToolSearchConfig(max_results=5, always_visible=["list_files"])) + ], +) @mcp.tool @@ -75,10 +80,9 @@ def read_file(path: str) -> str: # BM25 search with a higher result limit for this larger catalog. -# The `always_visible` option keeps specific tools in list_tools output -# alongside the search/call tools — useful for tools the LLM should -# always know about. -mcp.add_transform(BM25SearchTransform(max_results=5, always_visible=["list_files"])) +# The ToolSearch plugin is configured at server construction above — +# `always_visible` keeps specific tools in list_tools alongside the +# synthetic search/call tools. if __name__ == "__main__": diff --git a/examples/search/server_regex.py b/examples/tool_search/server_regex.py similarity index 81% rename from examples/search/server_regex.py rename to examples/tool_search/server_regex.py index 261ef6a55..6acaf9470 100644 --- a/examples/search/server_regex.py +++ b/examples/tool_search/server_regex.py @@ -10,13 +10,16 @@ Clients use `search_tools` with a regex pattern to find relevant tools, then `call_tool` to execute them by name. Run with: - uv run python examples/search/server_regex.py + uv run python examples/tool_search/server_regex.py """ from fastmcp import FastMCP -from fastmcp.server.transforms.search import RegexSearchTransform +from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig -mcp = FastMCP("Regex Search Demo") +mcp = FastMCP( + "Regex Search Demo", + plugins=[ToolSearch(ToolSearchConfig(strategy="regex", max_results=3))], +) # Register a variety of tools across different domains. @@ -65,9 +68,8 @@ def to_uppercase(text: str) -> str: return text.upper() -# Apply the regex search transform. -# max_results limits how many tools a single search returns. -mcp.add_transform(RegexSearchTransform(max_results=3)) +# The ToolSearch plugin is configured at server construction above — +# nothing else to wire here. if __name__ == "__main__": diff --git a/src/fastmcp/experimental/transforms/code_mode.py b/src/fastmcp/experimental/transforms/code_mode.py index 09fb61e07..19656bc28 100644 --- a/src/fastmcp/experimental/transforms/code_mode.py +++ b/src/fastmcp/experimental/transforms/code_mode.py @@ -11,12 +11,13 @@ from pydantic import Field from fastmcp.exceptions import NotFoundError from fastmcp.server.context import Context -from fastmcp.server.transforms import GetToolNext -from fastmcp.server.transforms.catalog import CatalogTransform -from fastmcp.server.transforms.search.base import ( +from fastmcp.server.plugins.tool_search.base import ( serialize_tools_for_output_json, serialize_tools_for_output_markdown, ) +from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform +from fastmcp.server.transforms import GetToolNext +from fastmcp.server.transforms.catalog import CatalogTransform from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.async_utils import is_coroutine_function from fastmcp.utilities.versions import VersionSpec @@ -199,8 +200,6 @@ class Search: default_limit: int | None = None, ) -> None: if search_fn is None: - from fastmcp.server.transforms.search.bm25 import BM25SearchTransform - _bm25 = BM25SearchTransform(max_results=default_limit or 50) search_fn = _bm25._search self._search_fn = search_fn diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py index 938785d48..e6bb9762c 100644 --- a/src/fastmcp/server/plugins/base.py +++ b/src/fastmcp/server/plugins/base.py @@ -381,11 +381,12 @@ class Plugin(Generic[C]): """ meta: ClassVar[PluginMeta] - """Class-level metadata. Auto-derived from the class name and a - placeholder version if the subclass doesn't declare one — fine for - in-code use. Declare `meta = PluginMeta(...)` (or - `PluginMeta.from_package(...)`) explicitly when publishing or when - Horizon/registry-facing metadata matters. + """Class-level metadata. Auto-derived from the class name with no + independent version if the subclass doesn't declare one — the + honest default for bundled first-party plugins. Declare `meta` + explicitly (or use `PluginMeta.from_package(...)`) when publishing + as a separate package or when Horizon/registry-facing metadata + matters. """ _config_cls: ClassVar[type[BaseModel]] = _EmptyConfig diff --git a/src/fastmcp/server/plugins/tool_search/__init__.py b/src/fastmcp/server/plugins/tool_search/__init__.py new file mode 100644 index 000000000..34414c7a3 --- /dev/null +++ b/src/fastmcp/server/plugins/tool_search/__init__.py @@ -0,0 +1,18 @@ +"""Tool-search plugin — replace the tool catalog with a search interface. + +The `ToolSearch` plugin is the public entry point: + + from fastmcp import FastMCP + from fastmcp.server.plugins.tool_search import ToolSearch + + mcp = FastMCP("Server", plugins=[ToolSearch()]) + +Transform classes (`BM25SearchTransform`, `RegexSearchTransform`, +`BaseSearchTransform`) live in `.bm25`, `.regex`, `.base` submodules +for advanced composition (custom transform stacks) but are not +re-exported here — import from the submodule path when needed. +""" + +from fastmcp.server.plugins.tool_search.plugin import ToolSearch, ToolSearchConfig + +__all__ = ["ToolSearch", "ToolSearchConfig"] diff --git a/src/fastmcp/server/plugins/tool_search/base.py b/src/fastmcp/server/plugins/tool_search/base.py new file mode 100644 index 000000000..584f52fe1 --- /dev/null +++ b/src/fastmcp/server/plugins/tool_search/base.py @@ -0,0 +1,265 @@ +"""Base class for search transforms. + +Search transforms replace `list_tools()` output with a small set of +synthetic tools — a search tool and a call-tool proxy — so LLMs can +discover tools on demand instead of receiving the full catalog. + +These classes are the implementation layer of the `ToolSearch` plugin. +Typical usage is via the plugin: + + from fastmcp import FastMCP + from fastmcp.server.plugins.tool_search import ToolSearch + + mcp = FastMCP("Server", plugins=[ToolSearch()]) + +`BM25SearchTransform` and `RegexSearchTransform` are exposed for +advanced composition (custom transform stacks) but the plugin is the +recommended entry point. +""" + +from abc import abstractmethod +from collections.abc import Awaitable, Callable, Sequence +from typing import Annotated, Any + +from fastmcp.server.context import Context +from fastmcp.server.transforms import GetToolNext +from fastmcp.server.transforms.catalog import CatalogTransform +from fastmcp.tools.base import Tool, ToolResult +from fastmcp.utilities.versions import VersionSpec + + +def _extract_searchable_text(tool: Tool) -> str: + """Combine tool name, description, and parameter info into searchable text.""" + parts = [tool.name] + if tool.description: + parts.append(tool.description) + + schema = tool.parameters + if schema: + properties = schema.get("properties", {}) + for param_name, param_info in properties.items(): + parts.append(param_name) + if isinstance(param_info, dict): + desc = param_info.get("description", "") + if desc: + parts.append(desc) + + return " ".join(parts) + + +def serialize_tools_for_output_json(tools: Sequence[Tool]) -> list[dict[str, Any]]: + """Serialize tools to the same dict format as `list_tools` output.""" + return [ + tool.to_mcp_tool().model_dump(mode="json", exclude_none=True) for tool in tools + ] + + +SearchResultSerializer = Callable[[Sequence[Tool]], Any | Awaitable[Any]] + + +async def _invoke_serializer( + serializer: SearchResultSerializer, tools: Sequence[Tool] +) -> Any: + """Call a serializer and await the result if it returns a coroutine.""" + result = serializer(tools) + if isinstance(result, Awaitable): + return await result + return result + + +def _union_type(branches: list[Any]) -> str: + branch_types = list(dict.fromkeys(_schema_type(b) for b in branches)) + if "null" not in branch_types: + return " | ".join(branch_types) if branch_types else "any" + non_null = [b for b in branch_types if b != "null"] + if not non_null: + return "null" + return f"{' | '.join(non_null)}?" + + +def _schema_type(schema: Any) -> str: + # Intentionally heuristic: the goal is a concise readable label, not a + # complete type system. Malformed schemas (e.g. {"type": ""}) → "any". + if not isinstance(schema, dict): + return "any" + t = schema.get("type") + if isinstance(t, str) and t: + if t == "array": + return f"{_schema_type(schema.get('items'))}[]" + if t == "null": + return "null" + return t + if "$ref" in schema: + return "object" + if "anyOf" in schema: + return _union_type(schema["anyOf"]) + if "oneOf" in schema: + return _union_type(schema["oneOf"]) + if "allOf" in schema: + # allOf = intersection / Pydantic composed model → always an object + return "object" + return "object" if "properties" in schema else "any" + + +def _schema_section(schema: dict[str, Any] | None, title: str) -> list[str]: + lines = [f"**{title}**"] + if not isinstance(schema, dict): + lines.append("- `value` (any)") + return lines + + props = schema.get("properties") + raw_required = schema.get("required") + req = set(raw_required) if isinstance(raw_required, list) else set() + if props is None: + # Not a properties-based schema — treat as a single unnamed value. + lines.append(f"- `value` ({_schema_type(schema)})") + return lines + if not props: + # Object schema with no properties — zero-argument tool. + lines.append("*(no parameters)*") + return lines + + for name, field in props.items(): + required = ", required" if name in req else "" + lines.append(f"- `{name}` ({_schema_type(field)}{required})") + return lines + + +def serialize_tools_for_output_markdown(tools: Sequence[Tool]) -> str: + """Serialize tools to compact markdown, using ~65-70% fewer tokens than JSON.""" + if not tools: + return "No tools matched the query." + blocks: list[str] = [] + for tool in tools: + lines = [f"### {tool.name}"] + if tool.description: + lines.extend(["", tool.description.strip()]) + lines.extend(["", *_schema_section(tool.parameters, "Parameters")]) + if tool.output_schema is not None: + lines.extend(["", *_schema_section(tool.output_schema, "Returns")]) + blocks.append("\n".join(lines)) + return "\n\n".join(blocks) + + +class BaseSearchTransform(CatalogTransform): + """Replace the tool listing with a search interface. + + When this transform is active, `list_tools()` returns only: + + * Any tools listed in `always_visible` (pinned). + * A **search tool** that finds tools matching a query. + * A **call_tool** proxy that executes tools discovered via search. + + Hidden tools remain callable — `get_tool()` delegates unknown + names downstream, so direct calls and the call-tool proxy both work. + + Search results respect the full auth pipeline: middleware, visibility + transforms, and component-level auth checks all apply. + + Args: + max_results: Maximum number of tools returned per search. + always_visible: Tool names that stay in the `list_tools` + output alongside the synthetic search/call tools. + search_tool_name: Name of the generated search tool. + call_tool_name: Name of the generated call-tool proxy. + """ + + def __init__( + self, + *, + max_results: int = 5, + always_visible: list[str] | None = None, + search_tool_name: str = "search_tools", + call_tool_name: str = "call_tool", + search_result_serializer: SearchResultSerializer | None = None, + ) -> None: + super().__init__() + self._max_results = max_results + self._always_visible = set(always_visible or []) + self._search_tool_name = search_tool_name + self._call_tool_name = call_tool_name + self._search_result_serializer: SearchResultSerializer = ( + search_result_serializer or serialize_tools_for_output_json + ) + + # ------------------------------------------------------------------ + # Transform interface + # ------------------------------------------------------------------ + + async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: + """Replace the catalog with pinned + synthetic search/call tools.""" + pinned = [t for t in tools if t.name in self._always_visible] + return [*pinned, self._make_search_tool(), self._make_call_tool()] + + async def get_tool( + self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None + ) -> Tool | None: + """Intercept synthetic tool names; delegate everything else.""" + if name == self._search_tool_name: + return self._make_search_tool() + if name == self._call_tool_name: + return self._make_call_tool() + return await call_next(name, version=version) + + # ------------------------------------------------------------------ + # Synthetic tools + # ------------------------------------------------------------------ + + @abstractmethod + def _make_search_tool(self) -> Tool: + """Create the search tool. Subclasses define the parameter schema.""" + ... + + def _make_call_tool(self) -> Tool: + """Create the call_tool proxy that executes discovered tools.""" + transform = self + search_name = self._search_tool_name + call_name = self._call_tool_name + + async def call_tool( + name: Annotated[str, "The name of the tool to call"], + arguments: Annotated[ + dict[str, Any] | None, "Arguments to pass to the tool" + ] = None, + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] + ) -> ToolResult: + if name in {transform._call_tool_name, transform._search_tool_name}: + raise ValueError( + f"{name!r} is a synthetic search tool and cannot be " + f"called via the {call_name!r} proxy" + ) + return await ctx.fastmcp.call_tool(name, arguments) + + return Tool.from_function( + fn=call_tool, + name=call_name, + description=( + f"Call a tool by name with the given arguments. " + f"Use this to execute tools discovered via {search_name!r}." + ), + ) + + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ + + async def _render_results(self, tools: Sequence[Tool]) -> Any: + return await _invoke_serializer(self._search_result_serializer, tools) + + # ------------------------------------------------------------------ + # Catalog access + # ------------------------------------------------------------------ + + async def _get_visible_tools(self, ctx: Context) -> Sequence[Tool]: + """Get the auth-filtered tool catalog, excluding pinned tools.""" + tools = await self.get_tool_catalog(ctx) + return [t for t in tools if t.name not in self._always_visible] + + # ------------------------------------------------------------------ + # Abstract search + # ------------------------------------------------------------------ + + @abstractmethod + async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: + """Search the given tools and return matches.""" + ... diff --git a/src/fastmcp/server/plugins/tool_search/bm25.py b/src/fastmcp/server/plugins/tool_search/bm25.py new file mode 100644 index 000000000..de9264a5e --- /dev/null +++ b/src/fastmcp/server/plugins/tool_search/bm25.py @@ -0,0 +1,152 @@ +"""BM25-based search transform.""" + +import hashlib +import math +import re +from collections.abc import Sequence +from typing import Annotated, Any + +from fastmcp.server.context import Context +from fastmcp.server.plugins.tool_search.base import ( + BaseSearchTransform, + SearchResultSerializer, + _extract_searchable_text, +) +from fastmcp.tools.base import Tool + + +def _tokenize(text: str) -> list[str]: + """Lowercase, split on non-alphanumeric, filter short tokens.""" + return [t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) > 1] + + +class _BM25Index: + """Self-contained BM25 Okapi index.""" + + def __init__(self, k1: float = 1.5, b: float = 0.75) -> None: + self.k1 = k1 + self.b = b + self._doc_tokens: list[list[str]] = [] + self._doc_lengths: list[int] = [] + self._avg_dl: float = 0.0 + self._df: dict[str, int] = {} + self._tf: list[dict[str, int]] = [] + self._n: int = 0 + + def build(self, documents: list[str]) -> None: + self._doc_tokens = [_tokenize(doc) for doc in documents] + self._doc_lengths = [len(tokens) for tokens in self._doc_tokens] + self._n = len(documents) + self._avg_dl = sum(self._doc_lengths) / self._n if self._n else 0.0 + + self._df = {} + self._tf = [] + for tokens in self._doc_tokens: + tf: dict[str, int] = {} + seen: set[str] = set() + for token in tokens: + tf[token] = tf.get(token, 0) + 1 + if token not in seen: + self._df[token] = self._df.get(token, 0) + 1 + seen.add(token) + self._tf.append(tf) + + def query(self, text: str, top_k: int) -> list[int]: + """Return indices of top_k documents sorted by BM25 score.""" + query_tokens = _tokenize(text) + if not query_tokens or not self._n: + return [] + + scores: list[float] = [0.0] * self._n + for token in query_tokens: + if token not in self._df: + continue + idf = math.log( + (self._n - self._df[token] + 0.5) / (self._df[token] + 0.5) + 1.0 + ) + for i in range(self._n): + tf = self._tf[i].get(token, 0) + if tf == 0: + continue + dl = self._doc_lengths[i] + numerator = tf * (self.k1 + 1) + denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avg_dl) + scores[i] += idf * numerator / denominator + + ranked = sorted(range(self._n), key=lambda i: scores[i], reverse=True) + return [i for i in ranked[:top_k] if scores[i] > 0] + + +def _catalog_hash(tools: Sequence[Tool]) -> str: + """SHA256 hash of sorted tool searchable text for staleness detection. + + Each tool's searchable text is hashed individually before being joined, + so the output is collision-resistant even when tool descriptions + contain the separator character. + """ + per_tool = sorted( + hashlib.sha256(_extract_searchable_text(t).encode()).hexdigest() for t in tools + ) + return hashlib.sha256("|".join(per_tool).encode()).hexdigest() + + +class BM25SearchTransform(BaseSearchTransform): + """Search transform using BM25 Okapi relevance ranking. + + Maintains an in-memory index that is lazily rebuilt when the tool + catalog changes — detected via a hash of each tool's searchable text + (name, description, and parameter names/descriptions combined). + """ + + def __init__( + self, + *, + max_results: int = 5, + always_visible: list[str] | None = None, + search_tool_name: str = "search_tools", + call_tool_name: str = "call_tool", + search_result_serializer: SearchResultSerializer | None = None, + ) -> None: + super().__init__( + max_results=max_results, + always_visible=always_visible, + search_tool_name=search_tool_name, + call_tool_name=call_tool_name, + search_result_serializer=search_result_serializer, + ) + self._index = _BM25Index() + self._indexed_tools: Sequence[Tool] = () + self._last_hash: str = "" + + def _make_search_tool(self) -> Tool: + transform = self + + async def search_tools( + query: Annotated[str, "Natural language query to search for tools"], + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] + ) -> str | list[dict[str, Any]]: + """Search for tools using natural language. + + Returns matching tool definitions ranked by relevance, + in the same format as list_tools. + """ + hidden = await transform._get_visible_tools(ctx) + results = await transform._search(hidden, query) + return await transform._render_results(results) + + return Tool.from_function(fn=search_tools, name=self._search_tool_name) + + async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: + current_hash = _catalog_hash(tools) + if current_hash != self._last_hash: + documents = [_extract_searchable_text(t) for t in tools] + new_index = _BM25Index(self._index.k1, self._index.b) + new_index.build(documents) + self._index, self._indexed_tools, self._last_hash = ( + new_index, + tools, + current_hash, + ) + + indices = self._index.query(query, self._max_results) + return [self._indexed_tools[i] for i in indices] diff --git a/src/fastmcp/server/plugins/tool_search/plugin.py b/src/fastmcp/server/plugins/tool_search/plugin.py new file mode 100644 index 000000000..cb1a08c69 --- /dev/null +++ b/src/fastmcp/server/plugins/tool_search/plugin.py @@ -0,0 +1,89 @@ +"""ToolSearch plugin: catalog-search-as-a-plugin. + +Wraps a `BaseSearchTransform` implementation (BM25 or regex) and +contributes it via the plugin `transforms()` hook. The transform +classes live in `.base`, `.bm25`, `.regex` as implementation detail; +user code should configure behavior through the plugin. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from fastmcp.server.plugins.base import Plugin +from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform +from fastmcp.server.plugins.tool_search.regex import RegexSearchTransform +from fastmcp.server.transforms import Transform + + +class ToolSearchConfig(BaseModel): + """Config model for the `ToolSearch` plugin.""" + + model_config = ConfigDict(extra="forbid") + + strategy: Literal["bm25", "regex"] = "bm25" + """Which matcher to use. BM25 ranks by relevance; regex filters by + pattern match.""" + + max_results: int = 5 + """Maximum tools returned per search.""" + + always_visible: list[str] = [] + """Tool names that stay in `list_tools` alongside the synthetic + search/call pair.""" + + search_tool_name: str = "search_tools" + """Name of the generated search tool.""" + + call_tool_name: str = "call_tool" + """Name of the generated call-tool proxy.""" + + +class ToolSearch(Plugin[ToolSearchConfig]): + """Collapse the tool catalog behind a search interface. + + With the plugin active, `list_tools()` returns only a pinned set + plus a generated `search_tools` / `call_tool` pair. Hidden tools + remain callable — direct calls and the call-tool proxy both work. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig + + # Default config: + mcp = FastMCP("Server", plugins=[ToolSearch()]) + + # Typed config (IDE completion + static validation): + mcp = FastMCP( + "Server", + plugins=[ToolSearch(ToolSearchConfig(strategy="regex", always_visible=["help"]))], + ) + + # Dict config (useful for loading from JSON/YAML): + mcp = FastMCP("Server", plugins=[ToolSearch({"strategy": "regex"})]) + ``` + """ + + # `meta` is intentionally omitted: the auto-derived default + # (`name="tool-search"`, `version=None`) is appropriate for a + # bundled first-party plugin with no independent release cadence. + # Declare `meta` explicitly (or use `PluginMeta.from_package(...)`) + # if/when we publish this as its own PyPI package. + + def transforms(self) -> list[Transform]: + cls = ( + BM25SearchTransform + if self.config.strategy == "bm25" + else RegexSearchTransform + ) + return [ + cls( + max_results=self.config.max_results, + always_visible=list(self.config.always_visible), + search_tool_name=self.config.search_tool_name, + call_tool_name=self.config.call_tool_name, + ) + ] diff --git a/src/fastmcp/server/plugins/tool_search/regex.py b/src/fastmcp/server/plugins/tool_search/regex.py new file mode 100644 index 000000000..40f5890e0 --- /dev/null +++ b/src/fastmcp/server/plugins/tool_search/regex.py @@ -0,0 +1,55 @@ +"""Regex-based search transform.""" + +import re +from collections.abc import Sequence +from typing import Annotated, Any + +from fastmcp.server.context import Context +from fastmcp.server.plugins.tool_search.base import ( + BaseSearchTransform, + _extract_searchable_text, +) +from fastmcp.tools.base import Tool + + +class RegexSearchTransform(BaseSearchTransform): + """Search transform using regex pattern matching. + + Tools are matched against their name, description, and parameter + information using `re.search` with `re.IGNORECASE`. + """ + + def _make_search_tool(self) -> Tool: + transform = self + + async def search_tools( + pattern: Annotated[ + str, + "Regex pattern to match against tool names, descriptions, and parameters", + ], + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] + ) -> str | list[dict[str, Any]]: + """Search for tools matching a regex pattern. + + Returns matching tool definitions in the same format as list_tools. + """ + hidden = await transform._get_visible_tools(ctx) + results = await transform._search(hidden, pattern) + return await transform._render_results(results) + + return Tool.from_function(fn=search_tools, name=self._search_tool_name) + + async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: + try: + compiled = re.compile(query, re.IGNORECASE) + except re.error: + return [] + + matches: list[Tool] = [] + for tool in tools: + text = _extract_searchable_text(tool) + if compiled.search(text): + matches.append(tool) + if len(matches) >= self._max_results: + break + return matches diff --git a/src/fastmcp/server/transforms/search/__init__.py b/src/fastmcp/server/transforms/search/__init__.py index 756244f23..772b9f7ed 100644 --- a/src/fastmcp/server/transforms/search/__init__.py +++ b/src/fastmcp/server/transforms/search/__init__.py @@ -1,29 +1,44 @@ -"""Search transforms for tool discovery. +"""Deprecation shim — search transforms moved to `fastmcp.server.plugins.tool_search`. -Search transforms collapse a large tool catalog into a search interface, -letting LLMs discover tools on demand instead of seeing the full list. +The preferred API is now the `ToolSearch` plugin: -Example: - ```python from fastmcp import FastMCP - from fastmcp.server.transforms.search import RegexSearchTransform + from fastmcp.server.plugins.tool_search import ToolSearch - mcp = FastMCP("Server") - mcp.add_transform(RegexSearchTransform()) - # list_tools now returns only search_tools + call_tool - ``` + mcp = FastMCP("Server", plugins=[ToolSearch()]) + +Transform classes remain importable from their new location +(`fastmcp.server.plugins.tool_search.{bm25,regex,base}`) for advanced +composition. This old path issues a `FastMCPDeprecationWarning` on +import — a `DeprecationWarning` subclass that fastmcp enables by +default (plain `DeprecationWarning` is suppressed by CPython's default +filter, so users wouldn't see the notice). """ -from fastmcp.server.transforms.search.base import ( +import warnings + +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.tool_search.base import ( + BaseSearchTransform, SearchResultSerializer, serialize_tools_for_output_json, serialize_tools_for_output_markdown, ) -from fastmcp.server.transforms.search.bm25 import BM25SearchTransform -from fastmcp.server.transforms.search.regex import RegexSearchTransform +from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform +from fastmcp.server.plugins.tool_search.regex import RegexSearchTransform + +warnings.warn( + "fastmcp.server.transforms.search has moved to " + "fastmcp.server.plugins.tool_search. Prefer the ToolSearch plugin: " + "`from fastmcp.server.plugins.tool_search import ToolSearch`. The old " + "import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, +) __all__ = [ "BM25SearchTransform", + "BaseSearchTransform", "RegexSearchTransform", "SearchResultSerializer", "serialize_tools_for_output_json", diff --git a/src/fastmcp/server/transforms/search/base.py b/src/fastmcp/server/transforms/search/base.py index 7368d62f5..eb69a8a6f 100644 --- a/src/fastmcp/server/transforms/search/base.py +++ b/src/fastmcp/server/transforms/search/base.py @@ -1,269 +1,10 @@ -"""Base class for search transforms. +"""Deprecated shim. See ``fastmcp.server.plugins.tool_search.base``.""" -Search transforms replace ``list_tools()`` output with a small set of -synthetic tools — a search tool and a call-tool proxy — so LLMs can -discover tools on demand instead of receiving the full catalog. - -All concrete search transforms (``RegexSearchTransform``, -``BM25SearchTransform``, etc.) inherit from ``BaseSearchTransform`` and -implement ``_make_search_tool()`` and ``_search()`` to provide their -specific search strategy. - -Example:: - - from fastmcp import FastMCP - from fastmcp.server.transforms.search import RegexSearchTransform - - mcp = FastMCP("Server") - - @mcp.tool - def add(a: int, b: int) -> int: ... - - @mcp.tool - def multiply(x: float, y: float) -> float: ... - - # Clients now see only ``search_tools`` and ``call_tool``. - # The original tools are discoverable via search. - mcp.add_transform(RegexSearchTransform()) -""" - -from abc import abstractmethod -from collections.abc import Awaitable, Callable, Sequence -from typing import Annotated, Any - -from fastmcp.server.context import Context -from fastmcp.server.transforms import GetToolNext -from fastmcp.server.transforms.catalog import CatalogTransform -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.versions import VersionSpec - - -def _extract_searchable_text(tool: Tool) -> str: - """Combine tool name, description, and parameter info into searchable text.""" - parts = [tool.name] - if tool.description: - parts.append(tool.description) - - schema = tool.parameters - if schema: - properties = schema.get("properties", {}) - for param_name, param_info in properties.items(): - parts.append(param_name) - if isinstance(param_info, dict): - desc = param_info.get("description", "") - if desc: - parts.append(desc) - - return " ".join(parts) - - -def serialize_tools_for_output_json(tools: Sequence[Tool]) -> list[dict[str, Any]]: - """Serialize tools to the same dict format as ``list_tools`` output.""" - return [ - tool.to_mcp_tool().model_dump(mode="json", exclude_none=True) for tool in tools - ] - - -SearchResultSerializer = Callable[[Sequence[Tool]], Any | Awaitable[Any]] - - -async def _invoke_serializer( - serializer: SearchResultSerializer, tools: Sequence[Tool] -) -> Any: - """Call a serializer and await the result if it returns a coroutine.""" - result = serializer(tools) - if isinstance(result, Awaitable): - return await result - return result - - -def _union_type(branches: list[Any]) -> str: - branch_types = list(dict.fromkeys(_schema_type(b) for b in branches)) - if "null" not in branch_types: - return " | ".join(branch_types) if branch_types else "any" - non_null = [b for b in branch_types if b != "null"] - if not non_null: - return "null" - return f"{' | '.join(non_null)}?" - - -def _schema_type(schema: Any) -> str: - # Intentionally heuristic: the goal is a concise readable label, not a - # complete type system. Malformed schemas (e.g. {"type": ""}) → "any". - if not isinstance(schema, dict): - return "any" - t = schema.get("type") - if isinstance(t, str) and t: - if t == "array": - return f"{_schema_type(schema.get('items'))}[]" - if t == "null": - return "null" - return t - if "$ref" in schema: - return "object" - if "anyOf" in schema: - return _union_type(schema["anyOf"]) - if "oneOf" in schema: - return _union_type(schema["oneOf"]) - if "allOf" in schema: - # allOf = intersection / Pydantic composed model → always an object - return "object" - return "object" if "properties" in schema else "any" - - -def _schema_section(schema: dict[str, Any] | None, title: str) -> list[str]: - lines = [f"**{title}**"] - if not isinstance(schema, dict): - lines.append("- `value` (any)") - return lines - - props = schema.get("properties") - raw_required = schema.get("required") - req = set(raw_required) if isinstance(raw_required, list) else set() - if props is None: - # Not a properties-based schema — treat as a single unnamed value. - lines.append(f"- `value` ({_schema_type(schema)})") - return lines - if not props: - # Object schema with no properties — zero-argument tool. - lines.append("*(no parameters)*") - return lines - - for name, field in props.items(): - required = ", required" if name in req else "" - lines.append(f"- `{name}` ({_schema_type(field)}{required})") - return lines - - -def serialize_tools_for_output_markdown(tools: Sequence[Tool]) -> str: - """Serialize tools to compact markdown, using ~65-70% fewer tokens than JSON.""" - if not tools: - return "No tools matched the query." - blocks: list[str] = [] - for tool in tools: - lines = [f"### {tool.name}"] - if tool.description: - lines.extend(["", tool.description.strip()]) - lines.extend(["", *_schema_section(tool.parameters, "Parameters")]) - if tool.output_schema is not None: - lines.extend(["", *_schema_section(tool.output_schema, "Returns")]) - blocks.append("\n".join(lines)) - return "\n\n".join(blocks) - - -class BaseSearchTransform(CatalogTransform): - """Replace the tool listing with a search interface. - - When this transform is active, ``list_tools()`` returns only: - - * Any tools listed in ``always_visible`` (pinned). - * A **search tool** that finds tools matching a query. - * A **call_tool** proxy that executes tools discovered via search. - - Hidden tools remain callable — ``get_tool()`` delegates unknown - names downstream, so direct calls and the call-tool proxy both work. - - Search results respect the full auth pipeline: middleware, visibility - transforms, and component-level auth checks all apply. - - Args: - max_results: Maximum number of tools returned per search. - always_visible: Tool names that stay in the ``list_tools`` - output alongside the synthetic search/call tools. - search_tool_name: Name of the generated search tool. - call_tool_name: Name of the generated call-tool proxy. - """ - - def __init__( - self, - *, - max_results: int = 5, - always_visible: list[str] | None = None, - search_tool_name: str = "search_tools", - call_tool_name: str = "call_tool", - search_result_serializer: SearchResultSerializer | None = None, - ) -> None: - super().__init__() - self._max_results = max_results - self._always_visible = set(always_visible or []) - self._search_tool_name = search_tool_name - self._call_tool_name = call_tool_name - self._search_result_serializer: SearchResultSerializer = ( - search_result_serializer or serialize_tools_for_output_json - ) - - # ------------------------------------------------------------------ - # Transform interface - # ------------------------------------------------------------------ - - async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - """Replace the catalog with pinned + synthetic search/call tools.""" - pinned = [t for t in tools if t.name in self._always_visible] - return [*pinned, self._make_search_tool(), self._make_call_tool()] - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - """Intercept synthetic tool names; delegate everything else.""" - if name == self._search_tool_name: - return self._make_search_tool() - if name == self._call_tool_name: - return self._make_call_tool() - return await call_next(name, version=version) - - # ------------------------------------------------------------------ - # Synthetic tools - # ------------------------------------------------------------------ - - @abstractmethod - def _make_search_tool(self) -> Tool: - """Create the search tool. Subclasses define the parameter schema.""" - ... - - def _make_call_tool(self) -> Tool: - """Create the call_tool proxy that executes discovered tools.""" - transform = self - - async def call_tool( - name: Annotated[str, "The name of the tool to call"], - arguments: Annotated[ - dict[str, Any] | None, "Arguments to pass to the tool" - ] = None, - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> ToolResult: - """Call a tool by name with the given arguments. - - Use this to execute tools discovered via search_tools. - """ - if name in {transform._call_tool_name, transform._search_tool_name}: - raise ValueError( - f"'{name}' is a synthetic search tool and cannot be called via the call_tool proxy" - ) - return await ctx.fastmcp.call_tool(name, arguments) - - return Tool.from_function(fn=call_tool, name=self._call_tool_name) - - # ------------------------------------------------------------------ - # Serialization - # ------------------------------------------------------------------ - - async def _render_results(self, tools: Sequence[Tool]) -> Any: - return await _invoke_serializer(self._search_result_serializer, tools) - - # ------------------------------------------------------------------ - # Catalog access - # ------------------------------------------------------------------ - - async def _get_visible_tools(self, ctx: Context) -> Sequence[Tool]: - """Get the auth-filtered tool catalog, excluding pinned tools.""" - tools = await self.get_tool_catalog(ctx) - return [t for t in tools if t.name not in self._always_visible] - - # ------------------------------------------------------------------ - # Abstract search - # ------------------------------------------------------------------ - - @abstractmethod - async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: - """Search the given tools and return matches.""" - ... +from fastmcp.server.plugins.tool_search.base import * # noqa: F403 +from fastmcp.server.plugins.tool_search.base import ( # noqa: F401 + BaseSearchTransform, + SearchResultSerializer, + _extract_searchable_text, + serialize_tools_for_output_json, + serialize_tools_for_output_markdown, +) diff --git a/src/fastmcp/server/transforms/search/bm25.py b/src/fastmcp/server/transforms/search/bm25.py index 447db8cac..725b1ee77 100644 --- a/src/fastmcp/server/transforms/search/bm25.py +++ b/src/fastmcp/server/transforms/search/bm25.py @@ -1,144 +1,3 @@ -"""BM25-based search transform.""" +"""Deprecated shim. See ``fastmcp.server.plugins.tool_search.bm25``.""" -import hashlib -import math -import re -from collections.abc import Sequence -from typing import Annotated, Any - -from fastmcp.server.context import Context -from fastmcp.server.transforms.search.base import ( - BaseSearchTransform, - SearchResultSerializer, - _extract_searchable_text, -) -from fastmcp.tools.base import Tool - - -def _tokenize(text: str) -> list[str]: - """Lowercase, split on non-alphanumeric, filter short tokens.""" - return [t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) > 1] - - -class _BM25Index: - """Self-contained BM25 Okapi index.""" - - def __init__(self, k1: float = 1.5, b: float = 0.75) -> None: - self.k1 = k1 - self.b = b - self._doc_tokens: list[list[str]] = [] - self._doc_lengths: list[int] = [] - self._avg_dl: float = 0.0 - self._df: dict[str, int] = {} - self._tf: list[dict[str, int]] = [] - self._n: int = 0 - - def build(self, documents: list[str]) -> None: - self._doc_tokens = [_tokenize(doc) for doc in documents] - self._doc_lengths = [len(tokens) for tokens in self._doc_tokens] - self._n = len(documents) - self._avg_dl = sum(self._doc_lengths) / self._n if self._n else 0.0 - - self._df = {} - self._tf = [] - for tokens in self._doc_tokens: - tf: dict[str, int] = {} - seen: set[str] = set() - for token in tokens: - tf[token] = tf.get(token, 0) + 1 - if token not in seen: - self._df[token] = self._df.get(token, 0) + 1 - seen.add(token) - self._tf.append(tf) - - def query(self, text: str, top_k: int) -> list[int]: - """Return indices of top_k documents sorted by BM25 score.""" - query_tokens = _tokenize(text) - if not query_tokens or not self._n: - return [] - - scores: list[float] = [0.0] * self._n - for token in query_tokens: - if token not in self._df: - continue - idf = math.log( - (self._n - self._df[token] + 0.5) / (self._df[token] + 0.5) + 1.0 - ) - for i in range(self._n): - tf = self._tf[i].get(token, 0) - if tf == 0: - continue - dl = self._doc_lengths[i] - numerator = tf * (self.k1 + 1) - denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avg_dl) - scores[i] += idf * numerator / denominator - - ranked = sorted(range(self._n), key=lambda i: scores[i], reverse=True) - return [i for i in ranked[:top_k] if scores[i] > 0] - - -def _catalog_hash(tools: Sequence[Tool]) -> str: - """SHA256 hash of sorted tool searchable text for staleness detection.""" - key = "|".join(sorted(_extract_searchable_text(t) for t in tools)) - return hashlib.sha256(key.encode()).hexdigest() - - -class BM25SearchTransform(BaseSearchTransform): - """Search transform using BM25 Okapi relevance ranking. - - Maintains an in-memory index that is lazily rebuilt when the tool - catalog changes (detected via a hash of tool names). - """ - - def __init__( - self, - *, - max_results: int = 5, - always_visible: list[str] | None = None, - search_tool_name: str = "search_tools", - call_tool_name: str = "call_tool", - search_result_serializer: SearchResultSerializer | None = None, - ) -> None: - super().__init__( - max_results=max_results, - always_visible=always_visible, - search_tool_name=search_tool_name, - call_tool_name=call_tool_name, - search_result_serializer=search_result_serializer, - ) - self._index = _BM25Index() - self._indexed_tools: Sequence[Tool] = () - self._last_hash: str = "" - - def _make_search_tool(self) -> Tool: - transform = self - - async def search_tools( - query: Annotated[str, "Natural language query to search for tools"], - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str | list[dict[str, Any]]: - """Search for tools using natural language. - - Returns matching tool definitions ranked by relevance, - in the same format as list_tools. - """ - hidden = await transform._get_visible_tools(ctx) - results = await transform._search(hidden, query) - return await transform._render_results(results) - - return Tool.from_function(fn=search_tools, name=self._search_tool_name) - - async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: - current_hash = _catalog_hash(tools) - if current_hash != self._last_hash: - documents = [_extract_searchable_text(t) for t in tools] - new_index = _BM25Index(self._index.k1, self._index.b) - new_index.build(documents) - self._index, self._indexed_tools, self._last_hash = ( - new_index, - tools, - current_hash, - ) - - indices = self._index.query(query, self._max_results) - return [self._indexed_tools[i] for i in indices] +from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform # noqa: F401 diff --git a/src/fastmcp/server/transforms/search/regex.py b/src/fastmcp/server/transforms/search/regex.py index f1b2d25a5..dabd51360 100644 --- a/src/fastmcp/server/transforms/search/regex.py +++ b/src/fastmcp/server/transforms/search/regex.py @@ -1,55 +1,3 @@ -"""Regex-based search transform.""" +"""Deprecated shim. See ``fastmcp.server.plugins.tool_search.regex``.""" -import re -from collections.abc import Sequence -from typing import Annotated, Any - -from fastmcp.server.context import Context -from fastmcp.server.transforms.search.base import ( - BaseSearchTransform, - _extract_searchable_text, -) -from fastmcp.tools.base import Tool - - -class RegexSearchTransform(BaseSearchTransform): - """Search transform using regex pattern matching. - - Tools are matched against their name, description, and parameter - information using ``re.search`` with ``re.IGNORECASE``. - """ - - def _make_search_tool(self) -> Tool: - transform = self - - async def search_tools( - pattern: Annotated[ - str, - "Regex pattern to match against tool names, descriptions, and parameters", - ], - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str | list[dict[str, Any]]: - """Search for tools matching a regex pattern. - - Returns matching tool definitions in the same format as list_tools. - """ - hidden = await transform._get_visible_tools(ctx) - results = await transform._search(hidden, pattern) - return await transform._render_results(results) - - return Tool.from_function(fn=search_tools, name=self._search_tool_name) - - async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: - try: - compiled = re.compile(query, re.IGNORECASE) - except re.error: - return [] - - matches: list[Tool] = [] - for tool in tools: - text = _extract_searchable_text(tool) - if compiled.search(text): - matches.append(tool) - if len(matches) >= self._max_results: - break - return matches +from fastmcp.server.plugins.tool_search.regex import RegexSearchTransform # noqa: F401 diff --git a/tests/experimental/transforms/test_code_mode_serialization.py b/tests/experimental/transforms/test_code_mode_serialization.py index a589ad790..6ce1e5b0e 100644 --- a/tests/experimental/transforms/test_code_mode_serialization.py +++ b/tests/experimental/transforms/test_code_mode_serialization.py @@ -3,7 +3,7 @@ from typing import Any import pytest from fastmcp import FastMCP -from fastmcp.server.transforms.search.base import ( +from fastmcp.server.plugins.tool_search.base import ( _schema_section, _schema_type, serialize_tools_for_output_markdown, diff --git a/tests/server/plugins/__init__.py b/tests/server/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/plugins/test_tool_search.py b/tests/server/plugins/test_tool_search.py new file mode 100644 index 000000000..f2a2990fa --- /dev/null +++ b/tests/server/plugins/test_tool_search.py @@ -0,0 +1,185 @@ +"""Tests for the ToolSearch plugin. + +These exercise the plugin-facing API (`ToolSearch`, its `Config`, +registration on a server) rather than the underlying transform +internals, which live in `tests/server/transforms/test_search.py`. +""" + +from __future__ import annotations + +import warnings + +import pytest +from pydantic import ValidationError + +from fastmcp import Client, FastMCP +from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig +from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform +from fastmcp.server.plugins.tool_search.regex import RegexSearchTransform + + +def _make_server_with_tools(plugins: list) -> FastMCP: + mcp = FastMCP("t", plugins=plugins) + + @mcp.tool + def add(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + @mcp.tool + def multiply(x: float, y: float) -> float: + """Multiply two numbers.""" + return x * y + + @mcp.tool + def search_files(pattern: str) -> list[str]: + """Search the filesystem for files matching a pattern.""" + return [] + + return mcp + + +class TestSearchPluginRegistration: + async def test_default_plugin_uses_bm25_and_hides_tools(self): + """With no config, ToolSearch uses BM25 and replaces list_tools output.""" + mcp = _make_server_with_tools([ToolSearch()]) + + async with Client(mcp) as c: + tools = await c.list_tools() + names = {t.name for t in tools} + + # Only the synthetic pair should be visible. + assert names == {"search_tools", "call_tool"} + + async def test_regex_strategy_dispatches_regex_transform(self): + plugin = ToolSearch(ToolSearchConfig(strategy="regex")) + transforms = plugin.transforms() + assert len(transforms) == 1 + assert isinstance(transforms[0], RegexSearchTransform) + + async def test_bm25_strategy_dispatches_bm25_transform(self): + plugin = ToolSearch(ToolSearchConfig(strategy="bm25")) + transforms = plugin.transforms() + assert len(transforms) == 1 + assert isinstance(transforms[0], BM25SearchTransform) + + async def test_always_visible_pins_tools_alongside_search_call(self): + mcp = _make_server_with_tools( + [ToolSearch(ToolSearchConfig(always_visible=["add"]))] + ) + + async with Client(mcp) as c: + tools = await c.list_tools() + names = {t.name for t in tools} + + assert names == {"add", "search_tools", "call_tool"} + + async def test_custom_tool_names_apply(self): + mcp = _make_server_with_tools( + [ + ToolSearch( + ToolSearchConfig(search_tool_name="find", call_tool_name="invoke") + ) + ] + ) + + async with Client(mcp) as c: + tools = await c.list_tools() + names = {t.name for t in tools} + by_name = {t.name: t for t in tools} + + assert names == {"find", "invoke"} + # The call-tool proxy's description must reference the actual + # configured search-tool name, not the hardcoded "search_tools" + # default — otherwise LLMs see misleading guidance pointing at + # a tool that doesn't exist under the user's rename. + assert by_name["invoke"].description is not None + assert "find" in by_name["invoke"].description + assert "search_tools" not in by_name["invoke"].description + + async def test_search_binds_searchconfig_via_generic_parameter(self): + """`Plugin[ToolSearchConfig]` makes ToolSearchConfig the validated config type.""" + assert ToolSearch._config_cls is ToolSearchConfig + + async def test_dict_config_still_accepted(self): + """Dict config path (inherited from Plugin base) constructs cleanly — + used for loading plugin configs from JSON/YAML.""" + plugin = ToolSearch({"strategy": "regex"}) + assert isinstance(plugin.transforms()[0], RegexSearchTransform) + + async def test_hidden_tool_is_still_callable(self): + """ToolSearch hides tools from list_tools but leaves them callable by name.""" + mcp = _make_server_with_tools([ToolSearch()]) + + async with Client(mcp) as c: + result = await c.call_tool("add", {"a": 2, "b": 3}) + assert result.data == 5 + + +class TestSearchPluginConfigValidation: + def test_unknown_strategy_rejected(self): + with pytest.raises((ValidationError, Exception), match="strategy"): + ToolSearchConfig(strategy="fuzzy") # ty: ignore[invalid-argument-type] + + def test_unknown_config_key_rejected(self): + with pytest.raises((ValidationError, Exception), match="forbid|extra"): + ToolSearchConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + + def test_default_meta_name_and_version(self): + """ToolSearch relies on Plugin's auto-derived meta: kebab-cased + class name, no independent version (bundled first-party plugin).""" + assert ToolSearch.meta.name == "tool-search" + assert ToolSearch.meta.version is None + + +class TestDeprecationShim: + """The old `fastmcp.server.transforms.search` path still works but warns.""" + + def test_old_package_import_emits_deprecation_warning(self): + # Force a fresh import so the module-level warning fires in this process. + import importlib + import sys + + from fastmcp.exceptions import FastMCPDeprecationWarning + + sys.modules.pop("fastmcp.server.transforms.search", None) + sys.modules.pop("fastmcp.server.transforms.search.base", None) + sys.modules.pop("fastmcp.server.transforms.search.bm25", None) + sys.modules.pop("fastmcp.server.transforms.search.regex", None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.import_module("fastmcp.server.transforms.search") + + # Must be FastMCPDeprecationWarning specifically — fastmcp installs a + # filter that surfaces that subclass even when the base + # DeprecationWarning is suppressed by CPython's default filter. + fastmcp_deprecations = [ + w for w in caught if issubclass(w.category, FastMCPDeprecationWarning) + ] + assert any( + "plugins.tool_search" in str(w.message) for w in fastmcp_deprecations + ), ( + f"expected FastMCPDeprecationWarning pointing at plugins.tool_search, " + f"got {[(w.category.__name__, str(w.message)) for w in caught]}" + ) + + def test_old_submodule_imports_still_resolve(self): + """Existing code that imports from the old submodule path keeps working.""" + from fastmcp.exceptions import FastMCPDeprecationWarning + + # Suppress the parent-package deprecation warning that fires on first + # import — otherwise running this test in isolation leaks the warning + # to pytest output. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FastMCPDeprecationWarning) + from fastmcp.server.transforms.search.bm25 import ( + BM25SearchTransform as OldBM25, + ) + from fastmcp.server.transforms.search.regex import ( + RegexSearchTransform as OldRegex, + ) + + # They're the same classes as the new path, not shims. + assert OldBM25 is BM25SearchTransform + assert OldRegex is RegexSearchTransform diff --git a/tests/server/transforms/test_search.py b/tests/server/transforms/test_search.py index ee0f5ad40..be8467e20 100644 --- a/tests/server/transforms/test_search.py +++ b/tests/server/transforms/test_search.py @@ -13,13 +13,13 @@ from mcp.types import TextContent from fastmcp import Client, FastMCP from fastmcp.server.context import Context from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.server.transforms import Visibility -from fastmcp.server.transforms.search.bm25 import ( +from fastmcp.server.plugins.tool_search.bm25 import ( BM25SearchTransform, _BM25Index, _catalog_hash, ) -from fastmcp.server.transforms.search.regex import RegexSearchTransform +from fastmcp.server.plugins.tool_search.regex import RegexSearchTransform +from fastmcp.server.transforms import Visibility from fastmcp.tools.base import Tool, ToolResult # --------------------------------------------------------------------------- From 19fa2fc33e744ebe05ea8735e1ae7b4fc3cca20b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:46:29 -0400 Subject: [PATCH 10/17] Convert code-mode to the CodeMode plugin (#4002) --- docs/servers/transforms/code-mode.mdx | 54 +- examples/code_mode/server.py | 14 +- .../experimental/transforms/code_mode.py | 603 ++---------------- .../server/plugins/code_mode/__init__.py | 42 ++ .../server/plugins/code_mode/discovery.py | 323 ++++++++++ .../server/plugins/code_mode/plugin.py | 129 ++++ .../server/plugins/code_mode/sandbox.py | 94 +++ .../server/plugins/code_mode/transform.py | 186 ++++++ .../plugins}/test_code_mode.py | 66 +- .../plugins}/test_code_mode_discovery.py | 50 +- tests/server/plugins/test_code_mode_plugin.py | 105 +++ .../plugins}/test_code_mode_serialization.py | 0 12 files changed, 1020 insertions(+), 646 deletions(-) create mode 100644 src/fastmcp/server/plugins/code_mode/__init__.py create mode 100644 src/fastmcp/server/plugins/code_mode/discovery.py create mode 100644 src/fastmcp/server/plugins/code_mode/plugin.py create mode 100644 src/fastmcp/server/plugins/code_mode/sandbox.py create mode 100644 src/fastmcp/server/plugins/code_mode/transform.py rename tests/{experimental/transforms => server/plugins}/test_code_mode.py (89%) rename tests/{experimental/transforms => server/plugins}/test_code_mode_discovery.py (92%) create mode 100644 tests/server/plugins/test_code_mode_plugin.py rename tests/{experimental/transforms => server/plugins}/test_code_mode_serialization.py (100%) diff --git a/docs/servers/transforms/code-mode.mdx b/docs/servers/transforms/code-mode.mdx index f09c22b6f..9edd5fbdd 100644 --- a/docs/servers/transforms/code-mode.mdx +++ b/docs/servers/transforms/code-mode.mdx @@ -10,9 +10,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx' - -CodeMode is experimental. The core interface is stable, but the specific discovery tools and their parameters may evolve as we learn more about what works best in practice. - + +The core interface is stable, but the specific discovery tools and their parameters may evolve as we learn more about what works best in practice. + Standard MCP tool usage has two scaling problems. First, every tool in the catalog is loaded into the LLM's context upfront — with hundreds of tools, that's tens of thousands of tokens spent before the LLM even reads the user's request. Second, every tool call is a round-trip: the LLM calls a tool, the result passes back through the context window, the LLM reasons about it, calls another tool, and so on. Intermediate results that only exist to feed the next step still burn tokens flowing through the model. @@ -26,13 +26,13 @@ The approach was introduced by Cloudflare in [Code Mode](https://blog.cloudflare CodeMode requires the `code-mode` extra for sandbox support. Install it with `pip install "fastmcp[code-mode]"`. -You take a normal server with normally registered tools and add a `CodeMode` transform. The transform wraps your existing tools in the code mode machinery — your tool functions don't change at all: +You take a normal server with normally registered tools and attach the `CodeMode` plugin. The plugin wraps your existing tools in the code mode machinery — your tool functions don't change at all: ```python from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import CodeMode +from fastmcp.server.plugins.code_mode import CodeMode -mcp = FastMCP("Server", transforms=[CodeMode()]) +mcp = FastMCP("Server", plugins=[CodeMode()]) @mcp.tool def add(x: int, y: int) -> int: @@ -165,7 +165,7 @@ If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` para `ListTools` isn't included in the defaults — for large catalogs, search-based discovery is more token-efficient. But for smaller catalogs (under ~20 tools), letting the LLM see everything upfront can be faster than multiple search round-trips: ```python -from fastmcp.experimental.transforms.code_mode import CodeMode, ListTools, GetSchemas +from fastmcp.server.plugins.code_mode import CodeMode, ListTools, GetSchemas code_mode = CodeMode( discovery_tools=[ListTools(), GetSchemas()], @@ -182,23 +182,23 @@ The default. The LLM searches for candidates, inspects schemas for the ones it w ```python from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import CodeMode +from fastmcp.server.plugins.code_mode import CodeMode -mcp = FastMCP("Server", transforms=[CodeMode()]) +mcp = FastMCP("Server", plugins=[CodeMode()]) ``` If your tools use [tags](/servers/tools#tags), add `GetTags` so the LLM can browse by category before searching — giving it four stages of progressive disclosure: ```python from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.experimental.transforms.code_mode import GetTags, Search, GetSchemas +from fastmcp.server.plugins.code_mode import CodeMode +from fastmcp.server.plugins.code_mode import GetTags, Search, GetSchemas code_mode = CodeMode( discovery_tools=[GetTags(), Search(), GetSchemas()], ) -mcp = FastMCP("Server", transforms=[code_mode]) +mcp = FastMCP("Server", plugins=[code_mode]) ``` ### Two-Stage @@ -207,14 +207,14 @@ Search returns parameter schemas inline, so the LLM can go straight from search ```python from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.experimental.transforms.code_mode import Search, GetSchemas +from fastmcp.server.plugins.code_mode import CodeMode +from fastmcp.server.plugins.code_mode import Search, GetSchemas code_mode = CodeMode( discovery_tools=[Search(default_detail="detailed"), GetSchemas()], ) -mcp = FastMCP("Server", transforms=[code_mode]) +mcp = FastMCP("Server", plugins=[code_mode]) ``` `GetSchemas` is still available as a fallback — the LLM can call it with `detail="full"` if it encounters a tool with complex nested parameters where the compact markdown isn't enough. @@ -225,7 +225,7 @@ Skip discovery entirely and bake tool instructions into the execute tool's descr ```python from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import CodeMode +from fastmcp.server.plugins.code_mode import CodeMode code_mode = CodeMode( discovery_tools=[], @@ -237,7 +237,7 @@ code_mode = CodeMode( ), ) -mcp = FastMCP("Server", transforms=[code_mode]) +mcp = FastMCP("Server", plugins=[code_mode]) ``` ## Custom Discovery Tools @@ -247,8 +247,8 @@ Discovery tools are composable — you can mix the built-ins with your own. Each Here's a minimal example: ```python -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.experimental.transforms.code_mode import GetToolCatalog, GetSchemas +from fastmcp.server.plugins.code_mode import CodeMode +from fastmcp.server.plugins.code_mode import GetToolCatalog, GetSchemas from fastmcp.server.context import Context from fastmcp.tools.tool import Tool @@ -268,7 +268,7 @@ The LLM sees the docstring of each discovery tool's inner function as its descri Discovery tools and the execute tool can also have custom names: ```python -from fastmcp.experimental.transforms.code_mode import Search, GetSchemas +from fastmcp.server.plugins.code_mode import Search, GetSchemas code_mode = CodeMode( discovery_tools=[ @@ -278,7 +278,7 @@ code_mode = CodeMode( execute_tool_name="run_workflow", ) -mcp = FastMCP("Server", transforms=[code_mode]) +mcp = FastMCP("Server", plugins=[code_mode]) ``` ## Sandbox Configuration @@ -288,14 +288,14 @@ mcp = FastMCP("Server", transforms=[code_mode]) The default `MontySandboxProvider` can enforce execution limits — timeouts, memory caps, recursion depth, and more. Without limits, LLM-generated scripts can run indefinitely. ```python -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.experimental.transforms.code_mode import MontySandboxProvider +from fastmcp.server.plugins.code_mode import CodeMode +from fastmcp.server.plugins.code_mode import MontySandboxProvider sandbox = MontySandboxProvider( limits={"max_duration_secs": 10, "max_memory": 50_000_000}, ) -mcp = FastMCP("Server", transforms=[CodeMode(sandbox_provider=sandbox)]) +mcp = FastMCP("Server", plugins=[CodeMode(sandbox_provider=sandbox)]) ``` All keys are optional — omit any to leave that dimension uncapped: @@ -316,8 +316,8 @@ You can replace the default sandbox with any object implementing the `SandboxPro from collections.abc import Callable from typing import Any -from fastmcp.experimental.transforms.code_mode import CodeMode -from fastmcp.experimental.transforms.code_mode import SandboxProvider +from fastmcp.server.plugins.code_mode import CodeMode +from fastmcp.server.plugins.code_mode import SandboxProvider class RemoteSandboxProvider: async def run( @@ -332,7 +332,7 @@ class RemoteSandboxProvider: mcp = FastMCP( "Server", - transforms=[CodeMode(sandbox_provider=RemoteSandboxProvider())], + plugins=[CodeMode(sandbox_provider=RemoteSandboxProvider())], ) ``` diff --git a/examples/code_mode/server.py b/examples/code_mode/server.py index 70aca8bfa..4589d7ba3 100644 --- a/examples/code_mode/server.py +++ b/examples/code_mode/server.py @@ -1,4 +1,4 @@ -"""Example: CodeMode transform — search and execute tools via code. +"""Example: CodeMode plugin — search and execute tools via code. CodeMode replaces the entire tool catalog with two meta-tools: `search` (keyword-based tool discovery) and `execute` (run Python code that chains @@ -13,9 +13,9 @@ Run with: """ from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import CodeMode +from fastmcp.server.plugins.code_mode import CodeMode -mcp = FastMCP("CodeMode Demo") +mcp = FastMCP("CodeMode Demo", plugins=[CodeMode()]) @mcp.tool @@ -74,10 +74,10 @@ def read_file(path: str) -> str: return f.read() -# CodeMode collapses all 8 tools into just `search` + `execute`. -# The LLM discovers tools via keyword search, then writes Python -# scripts that chain multiple tool calls in a single round-trip. -mcp.add_transform(CodeMode()) +# CodeMode (registered at construction above) collapses all 8 tools +# into just `search` + `execute`. The LLM discovers tools via keyword +# search, then writes Python scripts that chain multiple tool calls in +# a single round-trip. if __name__ == "__main__": diff --git a/src/fastmcp/experimental/transforms/code_mode.py b/src/fastmcp/experimental/transforms/code_mode.py index 19656bc28..beadff6c3 100644 --- a/src/fastmcp/experimental/transforms/code_mode.py +++ b/src/fastmcp/experimental/transforms/code_mode.py @@ -1,567 +1,62 @@ -import importlib -import json -from collections.abc import Awaitable, Callable, Sequence -from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol +"""Deprecation shim — code mode moved to `fastmcp.server.plugins.code_mode`. -if TYPE_CHECKING: - from pydantic_monty import ResourceLimits +The preferred API is now the `CodeMode` plugin: -from mcp.types import TextContent -from pydantic import Field + from fastmcp import FastMCP + from fastmcp.server.plugins.code_mode import CodeMode -from fastmcp.exceptions import NotFoundError -from fastmcp.server.context import Context -from fastmcp.server.plugins.tool_search.base import ( - serialize_tools_for_output_json, - serialize_tools_for_output_markdown, -) -from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform -from fastmcp.server.transforms import GetToolNext -from fastmcp.server.transforms.catalog import CatalogTransform -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.async_utils import is_coroutine_function -from fastmcp.utilities.versions import VersionSpec + mcp = FastMCP("Server", plugins=[CodeMode()]) -# --------------------------------------------------------------------------- -# Type aliases -# --------------------------------------------------------------------------- +For backcompat, this module keeps `CodeMode` bound to the **transform** +class (so existing `mcp.add_transform(CodeMode())` code keeps working). +The transform is also exported under its new canonical name, +`CodeModeTransform`. Sandbox providers, discovery-tool factories, and +related helpers re-export from the new location unchanged. -GetToolCatalog = Callable[[Context], Awaitable[Sequence[Tool]]] -"""Async callable that returns the auth-filtered tool catalog.""" - -SearchFn = Callable[[Sequence[Tool], str], Awaitable[Sequence[Tool]]] -"""Async callable that searches a tool sequence by query string.""" - -DiscoveryToolFactory = Callable[[GetToolCatalog], Tool] -"""Factory that receives catalog access and returns a synthetic Tool.""" - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]: - if is_coroutine_function(fn): - return fn - - async def wrapper(*args: Any, **kwargs: Any) -> Any: - return fn(*args, **kwargs) - - return wrapper - - -def _unwrap_tool_result(result: ToolResult) -> dict[str, Any] | str: - """Convert a ToolResult for use in the sandbox. - - - Output schema present → structured_content dict (matches the schema) - - Otherwise → concatenated text content as a string - """ - if result.structured_content is not None: - return result.structured_content - - parts: list[str] = [] - for content in result.content: - if isinstance(content, TextContent): - parts.append(content.text) - else: - parts.append(str(content)) - return "\n".join(parts) - - -# --------------------------------------------------------------------------- -# Sandbox providers -# --------------------------------------------------------------------------- - - -class SandboxProvider(Protocol): - """Interface for executing LLM-generated Python code in a sandbox. - - WARNING: The ``code`` parameter passed to ``run`` contains untrusted, - LLM-generated Python. Implementations MUST execute it in an isolated - sandbox — never with plain ``exec()``. Use ``MontySandboxProvider`` - (backed by ``pydantic-monty``) for production workloads. - """ - - async def run( - self, - code: str, - *, - inputs: dict[str, Any] | None = None, - external_functions: dict[str, Callable[..., Any]] | None = None, - ) -> Any: ... - - -class MontySandboxProvider: - """Sandbox provider backed by `pydantic-monty`. - - Args: - limits: Resource limits for sandbox execution. Supported keys: - ``max_duration_secs`` (float), ``max_allocations`` (int), - ``max_memory`` (int), ``max_recursion_depth`` (int), - ``gc_interval`` (int). All are optional; omit a key to - leave that limit uncapped. - """ - - def __init__( - self, - *, - limits: "ResourceLimits | None" = None, - ) -> None: - self.limits = limits - - async def run( - self, - code: str, - *, - inputs: dict[str, Any] | None = None, - external_functions: dict[str, Callable[..., Any]] | None = None, - ) -> Any: - try: - pydantic_monty = importlib.import_module("pydantic_monty") - except ModuleNotFoundError as exc: - raise ImportError( - "CodeMode requires pydantic-monty for the Monty sandbox provider. " - "Install it with `fastmcp[code-mode]` or pass a custom SandboxProvider." - ) from exc - - inputs = inputs or {} - async_functions = { - key: _ensure_async(value) - for key, value in (external_functions or {}).items() - } - - monty = pydantic_monty.Monty(code, inputs=list(inputs)) - return await monty.run_async( - inputs=inputs or None, - external_functions=async_functions or None, - limits=self.limits, - ) - - -# --------------------------------------------------------------------------- -# Built-in discovery tools -# --------------------------------------------------------------------------- - - -ToolDetailLevel = Literal["brief", "detailed", "full"] -"""Detail level for discovery tool output. - -- ``"brief"``: tool names and one-line descriptions -- ``"detailed"``: compact markdown with parameter names, types, and required markers -- ``"full"``: complete JSON schema +This path issues a `FastMCPDeprecationWarning` on import — a +`DeprecationWarning` subclass that fastmcp enables by default (plain +`DeprecationWarning` is suppressed by CPython's default filter, so +users wouldn't see the notice). """ - -def _render_tools(tools: Sequence[Tool], detail: ToolDetailLevel) -> str: - """Render tools at the requested detail level. - - The same detail value produces the same output format regardless of - which discovery tool calls this, so ``detail="detailed"`` on Search - gives identical formatting to ``detail="detailed"`` on GetSchemas. - """ - if not tools: - if detail == "full": - return json.dumps([], indent=2) - return "No tools matched the query." - if detail == "full": - return json.dumps(serialize_tools_for_output_json(tools), indent=2) - if detail == "detailed": - return serialize_tools_for_output_markdown(tools) - # brief - lines: list[str] = [] - for tool in tools: - desc = f": {tool.description}" if tool.description else "" - lines.append(f"- {tool.name}{desc}") - return "\n".join(lines) - - -class Search: - """Discovery tool factory that searches the catalog by query. - - Args: - search_fn: Async callable ``(tools, query) -> matching_tools``. - Defaults to BM25 ranking. - name: Name of the synthetic tool exposed to the LLM. - default_detail: Default detail level for search results. - ``"brief"`` returns tool names and descriptions only. - ``"detailed"`` returns compact markdown with parameter schemas. - ``"full"`` returns complete JSON tool definitions. - default_limit: Maximum number of results to return. - The LLM can override this per call. ``None`` means no limit. - """ - - def __init__( - self, - *, - search_fn: SearchFn | None = None, - name: str = "search", - default_detail: ToolDetailLevel | None = None, - default_limit: int | None = None, - ) -> None: - if search_fn is None: - _bm25 = BM25SearchTransform(max_results=default_limit or 50) - search_fn = _bm25._search - self._search_fn = search_fn - self._name = name - self._default_detail: ToolDetailLevel = default_detail or "brief" - self._default_limit = default_limit - - def __call__(self, get_catalog: GetToolCatalog) -> Tool: - search_fn = self._search_fn - default_detail = self._default_detail - default_limit = self._default_limit - - async def search( - query: Annotated[str, "Search query to find available tools"], - tags: Annotated[ - list[str] | None, - "Filter to tools with any of these tags before searching", - ] = None, - detail: Annotated[ - ToolDetailLevel, - "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", - ] = default_detail, - limit: Annotated[ - int | None, - "Maximum number of results to return", - ] = default_limit, - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str: - """Search for available tools by query. - - Returns matching tools ranked by relevance. - """ - catalog = await get_catalog(ctx) - catalog_size = len(catalog) - tools: Sequence[Tool] = catalog - if tags: - tag_set = set(tags) - has_untagged = "untagged" in tag_set - real_tags = tag_set - {"untagged"} - tools = [ - t - for t in tools - if (t.tags & real_tags) or (has_untagged and not t.tags) - ] - results = await search_fn(tools, query) - if limit is not None: - results = results[:limit] - rendered = _render_tools(results, detail) - if len(results) < catalog_size and detail != "full": - n = len(results) - rendered = f"{n} of {catalog_size} tools:\n\n{rendered}" - return rendered - - return Tool.from_function(fn=search, name=self._name) - - -class GetSchemas: - """Discovery tool factory that returns schemas for tools by name. - - Args: - name: Name of the synthetic tool exposed to the LLM. - default_detail: Default detail level for schema results. - ``"brief"`` returns tool names and descriptions only. - ``"detailed"`` renders compact markdown with parameter names, - types, and required markers. - ``"full"`` returns the complete JSON schema. - """ - - def __init__( - self, - *, - name: str = "get_schema", - default_detail: ToolDetailLevel | None = None, - ) -> None: - self._name = name - self._default_detail: ToolDetailLevel = default_detail or "detailed" - - def __call__(self, get_catalog: GetToolCatalog) -> Tool: - default_detail = self._default_detail - - async def get_schema( - tools: Annotated[ - list[str], - "List of tool names to get schemas for", - ], - detail: Annotated[ - ToolDetailLevel, - "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", - ] = default_detail, - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str: - """Get parameter schemas for specific tools. - - Use after searching to get the detail needed to call a tool. - """ - catalog = await get_catalog(ctx) - catalog_by_name = {t.name: t for t in catalog} - matched = [catalog_by_name[n] for n in tools if n in catalog_by_name] - not_found = [n for n in tools if n not in catalog_by_name] - - if not matched and not_found: - return f"Tools not found: {', '.join(not_found)}" - - if detail == "full": - data = serialize_tools_for_output_json(matched) - if not_found: - data.append({"not_found": not_found}) - return json.dumps(data, indent=2) - - result = _render_tools(matched, detail) - if not_found: - result += f"\n\nTools not found: {', '.join(not_found)}" - return result - - return Tool.from_function(fn=get_schema, name=self._name) - - -class GetTags: - """Discovery tool factory that lists tool tags from the catalog. - - Reads ``tool.tags`` from the catalog and groups tools by tag. Tools - without tags appear under ``"untagged"``. - - Args: - name: Name of the synthetic tool exposed to the LLM. - default_detail: Default detail level. - ``"brief"`` returns tag names with tool counts. - ``"full"`` lists all tools under each tag. - """ - - def __init__( - self, - *, - name: str = "tags", - default_detail: Literal["brief", "full"] | None = None, - ) -> None: - self._name = name - self._default_detail: Literal["brief", "full"] = default_detail or "brief" - - def __call__(self, get_catalog: GetToolCatalog) -> Tool: - default_detail = self._default_detail - - async def tags( - detail: Annotated[ - Literal["brief", "full"], - "Level of detail: 'brief' for tag names and counts, 'full' for tools listed under each tag", - ] = default_detail, - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str: - """List available tool tags. - - Use to browse available tools by tag before searching. - """ - catalog = await get_catalog(ctx) - by_tag: dict[str, list[Tool]] = {} - for tool in catalog: - if tool.tags: - for tag in tool.tags: - by_tag.setdefault(tag, []).append(tool) - else: - by_tag.setdefault("untagged", []).append(tool) - - if not by_tag: - return "No tools available." - - if detail == "brief": - lines = [ - f"- {tag} ({len(tools)} tool{'s' if len(tools) != 1 else ''})" - for tag, tools in sorted(by_tag.items()) - ] - return "\n".join(lines) - - blocks: list[str] = [] - for tag, tools in sorted(by_tag.items()): - lines = [f"### {tag}"] - for tool in tools: - desc = f": {tool.description}" if tool.description else "" - lines.append(f"- {tool.name}{desc}") - blocks.append("\n".join(lines)) - return "\n\n".join(blocks) - - return Tool.from_function(fn=tags, name=self._name) - - -class ListTools: - """Discovery tool factory that lists all tools in the catalog. - - Args: - name: Name of the synthetic tool exposed to the LLM. - default_detail: Default detail level. - ``"brief"`` returns tool names and one-line descriptions. - ``"detailed"`` returns compact markdown with parameter schemas. - ``"full"`` returns the complete JSON schema. - """ - - def __init__( - self, - *, - name: str = "list_tools", - default_detail: ToolDetailLevel | None = None, - ) -> None: - self._name = name - self._default_detail: ToolDetailLevel = default_detail or "brief" - - def __call__(self, get_catalog: GetToolCatalog) -> Tool: - default_detail = self._default_detail - - async def list_tools( - detail: Annotated[ - ToolDetailLevel, - "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", - ] = default_detail, - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str: - """List all available tools. - - Use to see the full catalog before searching or calling tools. - """ - catalog = await get_catalog(ctx) - return _render_tools(catalog, detail) - - return Tool.from_function(fn=list_tools, name=self._name) - - -# --------------------------------------------------------------------------- -# CodeMode -# --------------------------------------------------------------------------- - - -def _default_discovery_tools() -> list[DiscoveryToolFactory]: - return [Search(), GetSchemas()] - - -class CodeMode(CatalogTransform): - """Transform that collapses all tools into discovery + execute meta-tools. - - Discovery tools are composable via the ``discovery_tools`` parameter. - Each is a callable that receives catalog access and returns a ``Tool``. - By default, ``Search`` and ``GetSchemas`` are included for - progressive disclosure: search finds candidates, get_schema retrieves - parameter details, and execute runs code. - - The ``execute`` tool is always present and provides a sandboxed Python - environment with ``call_tool(name, params)`` in scope. - """ - - def __init__( - self, - *, - sandbox_provider: SandboxProvider | None = None, - discovery_tools: list[DiscoveryToolFactory] | None = None, - execute_tool_name: str = "execute", - execute_description: str | None = None, - ) -> None: - super().__init__() - self.execute_tool_name = execute_tool_name - self.execute_description = execute_description - self.sandbox_provider = sandbox_provider or MontySandboxProvider() - - self._discovery_factories = ( - discovery_tools - if discovery_tools is not None - else _default_discovery_tools() - ) - self._built_discovery_tools: list[Tool] | None = None - self._cached_execute_tool: Tool | None = None - - def _build_discovery_tools(self) -> list[Tool]: - if self._built_discovery_tools is None: - tools = [ - factory(self.get_tool_catalog) for factory in self._discovery_factories - ] - names = {t.name for t in tools} - if self.execute_tool_name in names: - raise ValueError( - f"Discovery tool name '{self.execute_tool_name}' " - f"collides with execute_tool_name." - ) - if len(names) != len(tools): - raise ValueError("Discovery tools must have unique names.") - self._built_discovery_tools = tools - return self._built_discovery_tools - - async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - return [*self._build_discovery_tools(), self._get_execute_tool()] - - async def get_tool( - self, - name: str, - call_next: GetToolNext, - *, - version: VersionSpec | None = None, - ) -> Tool | None: - for tool in self._build_discovery_tools(): - if tool.name == name: - return tool - if name == self.execute_tool_name: - return self._get_execute_tool() - return await call_next(name, version=version) - - def _build_execute_description(self) -> str: - if self.execute_description is not None: - return self.execute_description - - return ( - "Chain `await call_tool(...)` calls in one Python block; prefer returning the final answer from a single block.\n" - "Use `return` to produce output.\n" - "Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope." - ) - - @staticmethod - def _find_tool(name: str, tools: Sequence[Tool]) -> Tool | None: - """Find a tool by name from a pre-fetched list.""" - for tool in tools: - if tool.name == name: - return tool - return None - - def _get_execute_tool(self) -> Tool: - if self._cached_execute_tool is None: - self._cached_execute_tool = self._make_execute_tool() - return self._cached_execute_tool - - def _make_execute_tool(self) -> Tool: - transform = self - - async def execute( - code: Annotated[ - str, - Field( - description=( - "Python async code to execute tool calls via call_tool(name, arguments)" - ) - ), - ], - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> Any: - """Execute tool calls using Python code.""" - - async def call_tool(tool_name: str, params: dict[str, Any]) -> Any: - backend_tools = await transform.get_tool_catalog(ctx) - tool = transform._find_tool(tool_name, backend_tools) - if tool is None: - raise NotFoundError(f"Unknown tool: {tool_name}") - - result = await ctx.fastmcp.call_tool(tool.name, params) - return _unwrap_tool_result(result) - - return await transform.sandbox_provider.run( - code, - external_functions={"call_tool": call_tool}, - ) - - return Tool.from_function( - fn=execute, - name=self.execute_tool_name, - description=self._build_execute_description(), - ) - +import warnings + +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.code_mode.discovery import ( + DiscoveryToolFactory, + GetSchemas, + GetTags, + GetToolCatalog, + ListTools, + Search, +) +from fastmcp.server.plugins.code_mode.sandbox import ( + MontySandboxProvider, + SandboxProvider, +) +from fastmcp.server.plugins.code_mode.transform import CodeModeTransform + +# `CodeMode` at this old path stays bound to the transform class, so +# `mcp.add_transform(CodeMode(...))` keeps working. The new plugin class +# is at `fastmcp.server.plugins.code_mode.CodeMode`. +CodeMode = CodeModeTransform + +warnings.warn( + "fastmcp.experimental.transforms.code_mode has moved to " + "fastmcp.server.plugins.code_mode. Prefer the CodeMode plugin: " + "`from fastmcp.server.plugins.code_mode import CodeMode` and pass " + "it via `plugins=[CodeMode(...)]`. At this old path, `CodeMode` " + "remains the transform class (also exported as `CodeModeTransform`) " + "for backcompat. The old import path will be removed in a future " + "release.", + FastMCPDeprecationWarning, + stacklevel=2, +) __all__ = [ "CodeMode", + "CodeModeTransform", + "DiscoveryToolFactory", "GetSchemas", "GetTags", "GetToolCatalog", diff --git a/src/fastmcp/server/plugins/code_mode/__init__.py b/src/fastmcp/server/plugins/code_mode/__init__.py new file mode 100644 index 000000000..78002ff9e --- /dev/null +++ b/src/fastmcp/server/plugins/code_mode/__init__.py @@ -0,0 +1,42 @@ +"""Code-mode plugin — discovery + sandboxed Python execution in place of the tool catalog. + +The `CodeMode` plugin is the public entry point: + + from fastmcp import FastMCP + from fastmcp.server.plugins.code_mode import CodeMode + + mcp = FastMCP("Server", plugins=[CodeMode()]) + +Discovery-tool factories (`Search`, `GetSchemas`, `GetTags`, +`ListTools`) and the sandbox-provider protocol (`SandboxProvider`, +`MontySandboxProvider`) are re-exported for custom composition. The +low-level `CodeModeTransform` lives in `.transform` for advanced users +who want to stack it directly with other transforms. +""" + +from fastmcp.server.plugins.code_mode.discovery import ( + DiscoveryToolFactory, + GetSchemas, + GetTags, + GetToolCatalog, + ListTools, + Search, +) +from fastmcp.server.plugins.code_mode.plugin import CodeMode, CodeModeConfig +from fastmcp.server.plugins.code_mode.sandbox import ( + MontySandboxProvider, + SandboxProvider, +) + +__all__ = [ + "CodeMode", + "CodeModeConfig", + "DiscoveryToolFactory", + "GetSchemas", + "GetTags", + "GetToolCatalog", + "ListTools", + "MontySandboxProvider", + "SandboxProvider", + "Search", +] diff --git a/src/fastmcp/server/plugins/code_mode/discovery.py b/src/fastmcp/server/plugins/code_mode/discovery.py new file mode 100644 index 000000000..fe1240821 --- /dev/null +++ b/src/fastmcp/server/plugins/code_mode/discovery.py @@ -0,0 +1,323 @@ +"""Discovery tool factories for the CodeMode plugin. + +A discovery tool is a synthetic meta-tool the LLM uses to explore the real +tool catalog before calling anything. Each factory here is a callable +that receives catalog access (`GetToolCatalog`) and returns a ready-to- +publish `Tool`. They compose via the `discovery_tools` parameter on +`CodeMode`. + +The four built-in factories cover the common discovery patterns: + +* `Search` — query the catalog by text (BM25 by default). +* `GetSchemas` — fetch parameter schemas for a named list of tools. +* `GetTags` — browse the catalog grouped by tag. +* `ListTools` — dump every tool at a configurable detail level. + +A typical progressive-disclosure setup pairs `Search` with `GetSchemas`: +the LLM searches to find candidates, then fetches schemas only for the +tools it actually plans to call. +""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable, Sequence +from typing import Annotated, Literal + +from fastmcp.server.context import Context +from fastmcp.server.plugins.tool_search.base import ( + serialize_tools_for_output_json, + serialize_tools_for_output_markdown, +) +from fastmcp.tools.base import Tool + +GetToolCatalog = Callable[[Context], Awaitable[Sequence[Tool]]] +"""Async callable that returns the auth-filtered tool catalog.""" + +SearchFn = Callable[[Sequence[Tool], str], Awaitable[Sequence[Tool]]] +"""Async callable that searches a tool sequence by query string.""" + +DiscoveryToolFactory = Callable[[GetToolCatalog], Tool] +"""Factory that receives catalog access and returns a synthetic Tool.""" + + +ToolDetailLevel = Literal["brief", "detailed", "full"] +"""Detail level for discovery tool output. + +- `"brief"`: tool names and one-line descriptions +- `"detailed"`: compact markdown with parameter names, types, and required markers +- `"full"`: complete JSON schema +""" + + +def _render_tools(tools: Sequence[Tool], detail: ToolDetailLevel) -> str: + """Render tools at the requested detail level. + + The same detail value produces the same output format regardless of + which discovery tool calls this, so `detail="detailed"` on Search + gives identical formatting to `detail="detailed"` on GetSchemas. + """ + if not tools: + if detail == "full": + return json.dumps([], indent=2) + return "No tools matched the query." + if detail == "full": + return json.dumps(serialize_tools_for_output_json(tools), indent=2) + if detail == "detailed": + return serialize_tools_for_output_markdown(tools) + # brief + lines: list[str] = [] + for tool in tools: + desc = f": {tool.description}" if tool.description else "" + lines.append(f"- {tool.name}{desc}") + return "\n".join(lines) + + +class Search: + """Discovery tool factory that searches the catalog by query. + + Args: + search_fn: Async callable `(tools, query) -> matching_tools`. + Defaults to BM25 ranking. + name: Name of the synthetic tool exposed to the LLM. + default_detail: Default detail level for search results. + `"brief"` returns tool names and descriptions only. + `"detailed"` returns compact markdown with parameter schemas. + `"full"` returns complete JSON tool definitions. + default_limit: Maximum number of results to return. The LLM can + override this per call. `None` means no limit. + """ + + def __init__( + self, + *, + search_fn: SearchFn | None = None, + name: str = "search", + default_detail: ToolDetailLevel | None = None, + default_limit: int | None = None, + ) -> None: + if search_fn is None: + from fastmcp.server.plugins.tool_search.bm25 import BM25SearchTransform + + _bm25 = BM25SearchTransform(max_results=default_limit or 50) + search_fn = _bm25._search + self._search_fn = search_fn + self._name = name + self._default_detail: ToolDetailLevel = default_detail or "brief" + self._default_limit = default_limit + + def __call__(self, get_catalog: GetToolCatalog) -> Tool: + search_fn = self._search_fn + default_detail = self._default_detail + default_limit = self._default_limit + + async def search( + query: Annotated[str, "Search query to find available tools"], + tags: Annotated[ + list[str] | None, + "Filter to tools with any of these tags before searching", + ] = None, + detail: Annotated[ + ToolDetailLevel, + "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", + ] = default_detail, + limit: Annotated[ + int | None, + "Maximum number of results to return", + ] = default_limit, + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] + ) -> str: + """Search for available tools by query. + + Returns matching tools ranked by relevance. + """ + catalog = await get_catalog(ctx) + catalog_size = len(catalog) + tools: Sequence[Tool] = catalog + if tags: + tag_set = set(tags) + has_untagged = "untagged" in tag_set + real_tags = tag_set - {"untagged"} + tools = [ + t + for t in tools + if (t.tags & real_tags) or (has_untagged and not t.tags) + ] + results = await search_fn(tools, query) + if limit is not None: + results = results[:limit] + rendered = _render_tools(results, detail) + if len(results) < catalog_size and detail != "full": + n = len(results) + rendered = f"{n} of {catalog_size} tools:\n\n{rendered}" + return rendered + + return Tool.from_function(fn=search, name=self._name) + + +class GetSchemas: + """Discovery tool factory that returns schemas for tools by name. + + Args: + name: Name of the synthetic tool exposed to the LLM. + default_detail: Default detail level for schema results. + `"brief"` returns tool names and descriptions only. + `"detailed"` renders compact markdown with parameter names, + types, and required markers. + `"full"` returns the complete JSON schema. + """ + + def __init__( + self, + *, + name: str = "get_schema", + default_detail: ToolDetailLevel | None = None, + ) -> None: + self._name = name + self._default_detail: ToolDetailLevel = default_detail or "detailed" + + def __call__(self, get_catalog: GetToolCatalog) -> Tool: + default_detail = self._default_detail + + async def get_schema( + tools: Annotated[ + list[str], + "List of tool names to get schemas for", + ], + detail: Annotated[ + ToolDetailLevel, + "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", + ] = default_detail, + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] + ) -> str: + """Get parameter schemas for specific tools. + + Use after searching to get the detail needed to call a tool. + """ + catalog = await get_catalog(ctx) + catalog_by_name = {t.name: t for t in catalog} + matched = [catalog_by_name[n] for n in tools if n in catalog_by_name] + not_found = [n for n in tools if n not in catalog_by_name] + + if not matched and not_found: + return f"Tools not found: {', '.join(not_found)}" + + if detail == "full": + data = serialize_tools_for_output_json(matched) + if not_found: + data.append({"not_found": not_found}) + return json.dumps(data, indent=2) + + result = _render_tools(matched, detail) + if not_found: + result += f"\n\nTools not found: {', '.join(not_found)}" + return result + + return Tool.from_function(fn=get_schema, name=self._name) + + +class GetTags: + """Discovery tool factory that lists tool tags from the catalog. + + Reads `tool.tags` from the catalog and groups tools by tag. Tools + without tags appear under `"untagged"`. + + Args: + name: Name of the synthetic tool exposed to the LLM. + default_detail: Default detail level. + `"brief"` returns tag names with tool counts. + `"full"` lists all tools under each tag. + """ + + def __init__( + self, + *, + name: str = "tags", + default_detail: Literal["brief", "full"] | None = None, + ) -> None: + self._name = name + self._default_detail: Literal["brief", "full"] = default_detail or "brief" + + def __call__(self, get_catalog: GetToolCatalog) -> Tool: + default_detail = self._default_detail + + async def tags( + detail: Annotated[ + Literal["brief", "full"], + "Level of detail: 'brief' for tag names and counts, 'full' for tools listed under each tag", + ] = default_detail, + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] + ) -> str: + """List available tool tags. + + Use to browse available tools by tag before searching. + """ + catalog = await get_catalog(ctx) + by_tag: dict[str, list[Tool]] = {} + for tool in catalog: + if tool.tags: + for tag in tool.tags: + by_tag.setdefault(tag, []).append(tool) + else: + by_tag.setdefault("untagged", []).append(tool) + + if not by_tag: + return "No tools available." + + if detail == "brief": + lines = [ + f"- {tag} ({len(tools)} tool{'s' if len(tools) != 1 else ''})" + for tag, tools in sorted(by_tag.items()) + ] + return "\n".join(lines) + + blocks: list[str] = [] + for tag, tools in sorted(by_tag.items()): + lines = [f"### {tag}"] + for tool in tools: + desc = f": {tool.description}" if tool.description else "" + lines.append(f"- {tool.name}{desc}") + blocks.append("\n".join(lines)) + return "\n\n".join(blocks) + + return Tool.from_function(fn=tags, name=self._name) + + +class ListTools: + """Discovery tool factory that lists all tools in the catalog. + + Args: + name: Name of the synthetic tool exposed to the LLM. + default_detail: Default detail level. + `"brief"` returns tool names and one-line descriptions. + `"detailed"` returns compact markdown with parameter schemas. + `"full"` returns the complete JSON schema. + """ + + def __init__( + self, + *, + name: str = "list_tools", + default_detail: ToolDetailLevel | None = None, + ) -> None: + self._name = name + self._default_detail: ToolDetailLevel = default_detail or "brief" + + def __call__(self, get_catalog: GetToolCatalog) -> Tool: + default_detail = self._default_detail + + async def list_tools( + detail: Annotated[ + ToolDetailLevel, + "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", + ] = default_detail, + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] + ) -> str: + """List all available tools. + + Use to see the full catalog before searching or calling tools. + """ + catalog = await get_catalog(ctx) + return _render_tools(catalog, detail) + + return Tool.from_function(fn=list_tools, name=self._name) diff --git a/src/fastmcp/server/plugins/code_mode/plugin.py b/src/fastmcp/server/plugins/code_mode/plugin.py new file mode 100644 index 000000000..a557aee7d --- /dev/null +++ b/src/fastmcp/server/plugins/code_mode/plugin.py @@ -0,0 +1,129 @@ +"""CodeMode plugin: tool execution via LLM-generated code. + +`CodeMode` replaces the entire tool catalog with two classes of +meta-tool — discovery tools (search, get_schema, etc.) and a single +`execute` tool that runs LLM-generated Python in a sandbox. The model +discovers what's available on demand and chains calls inside one +sandboxed code block, which dramatically cuts round-trips and context +for servers with many tools. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +from fastmcp.server.plugins.base import Plugin +from fastmcp.server.plugins.code_mode.discovery import DiscoveryToolFactory +from fastmcp.server.plugins.code_mode.sandbox import ( + MontySandboxProvider, + SandboxProvider, +) +from fastmcp.server.plugins.code_mode.transform import CodeModeTransform +from fastmcp.server.transforms import Transform + + +class CodeModeConfig(BaseModel): + """Config model for the `CodeMode` plugin. + + Only covers JSON-serializable settings — the sandbox provider and + discovery-tool factories are passed through `CodeMode.__init__` + directly because they're real Python objects. + """ + + model_config = ConfigDict(extra="forbid") + + sandbox: Literal["monty"] = "monty" + """Built-in sandbox provider to use. `"monty"` uses + `pydantic-monty`. For a custom provider, pass `sandbox_provider=...` + to `CodeMode.__init__` instead.""" + + sandbox_limits: dict[str, Any] | None = None + """Resource limits for the default Monty sandbox. Keys: + `max_duration_secs`, `max_allocations`, `max_memory`, + `max_recursion_depth`, `gc_interval`. All optional.""" + + execute_tool_name: str = "execute" + """Name of the generated execute tool.""" + + execute_description: str | None = None + """Override the default description of the execute tool. `None` + keeps the built-in guidance.""" + + +class CodeMode(Plugin[CodeModeConfig]): + """Collapse the tool catalog behind discovery + code-execution meta-tools. + + Users write a CodeMode-enabled server exactly like a normal server; + the plugin takes care of hiding the real tools and exposing search + / get_schema / execute in their place. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.code_mode import CodeMode + + mcp = FastMCP("Server", plugins=[CodeMode()]) + ``` + + For a custom sandbox or custom discovery-tool set, pass Python + objects through `__init__`: + + ```python + from fastmcp.server.plugins.code_mode import ( + CodeMode, + CodeModeConfig, + GetSchemas, + ListTools, + ) + + mcp = FastMCP( + "Server", + plugins=[ + CodeMode( + CodeModeConfig(execute_tool_name="run"), + sandbox_provider=my_custom_sandbox, + discovery_tools=[ListTools(), GetSchemas()], + ) + ], + ) + ``` + """ + + # `meta` is auto-derived (name="code-mode", version=None) — the right + # answer for a bundled first-party plugin. Declare `meta` explicitly + # (or use `PluginMeta.from_package(...)`) if published separately. + + def __init__( + self, + config: CodeModeConfig | dict[str, Any] | None = None, + *, + sandbox_provider: SandboxProvider | None = None, + discovery_tools: list[DiscoveryToolFactory] | None = None, + ) -> None: + super().__init__(config) + self._sandbox_override = sandbox_provider + self._discovery_override = discovery_tools + + def transforms(self) -> list[Transform]: + sandbox = self._sandbox_override or self._build_default_sandbox() + return [ + CodeModeTransform( + sandbox_provider=sandbox, + discovery_tools=self._discovery_override, + execute_tool_name=self.config.execute_tool_name, + execute_description=self.config.execute_description, + ) + ] + + def _build_default_sandbox(self) -> SandboxProvider: + limits_dict = self.config.sandbox_limits + if limits_dict is None: + return MontySandboxProvider() + + # Defer the import so Monty is only a hard dependency when + # `sandbox_limits` is actually configured. + from pydantic_monty import ResourceLimits + + return MontySandboxProvider(limits=ResourceLimits(**limits_dict)) diff --git a/src/fastmcp/server/plugins/code_mode/sandbox.py b/src/fastmcp/server/plugins/code_mode/sandbox.py new file mode 100644 index 000000000..3b312bcd8 --- /dev/null +++ b/src/fastmcp/server/plugins/code_mode/sandbox.py @@ -0,0 +1,94 @@ +"""Sandbox providers for the CodeMode plugin. + +A `SandboxProvider` is the component that actually executes LLM-generated +Python code. The default `MontySandboxProvider` delegates to +`pydantic-monty` for isolated execution; alternative providers can plug in +any other sandbox (remote process, WASM, a containerized worker, etc.) +by implementing the `SandboxProvider` protocol. +""" + +from __future__ import annotations + +import importlib +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Protocol + +from fastmcp.utilities.async_utils import is_coroutine_function + +if TYPE_CHECKING: + from pydantic_monty import ResourceLimits + + +def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]: + if is_coroutine_function(fn): + return fn + + async def wrapper(*args: Any, **kwargs: Any) -> Any: + return fn(*args, **kwargs) + + return wrapper + + +class SandboxProvider(Protocol): + """Interface for executing LLM-generated Python code in a sandbox. + + WARNING: The `code` parameter passed to `run` contains untrusted, + LLM-generated Python. Implementations MUST execute it in an isolated + sandbox — never with plain `exec()`. Use `MontySandboxProvider` + (backed by `pydantic-monty`) for production workloads. + """ + + async def run( + self, + code: str, + *, + inputs: dict[str, Any] | None = None, + external_functions: dict[str, Callable[..., Any]] | None = None, + ) -> Any: ... + + +class MontySandboxProvider: + """Sandbox provider backed by `pydantic-monty`. + + Args: + limits: Resource limits for sandbox execution. Supported keys: + `max_duration_secs` (float), `max_allocations` (int), + `max_memory` (int), `max_recursion_depth` (int), + `gc_interval` (int). All are optional; omit a key to leave + that limit uncapped. + """ + + def __init__( + self, + *, + limits: ResourceLimits | None = None, + ) -> None: + self.limits = limits + + async def run( + self, + code: str, + *, + inputs: dict[str, Any] | None = None, + external_functions: dict[str, Callable[..., Any]] | None = None, + ) -> Any: + try: + pydantic_monty = importlib.import_module("pydantic_monty") + except ModuleNotFoundError as exc: + raise ImportError( + "CodeMode requires pydantic-monty for the Monty sandbox provider. " + "Install it with `fastmcp[code-mode]` or pass a custom SandboxProvider." + ) from exc + + inputs = inputs or {} + async_functions = { + key: _ensure_async(value) + for key, value in (external_functions or {}).items() + } + + monty = pydantic_monty.Monty(code, inputs=list(inputs)) + return await monty.run_async( + inputs=inputs or None, + external_functions=async_functions or None, + limits=self.limits, + ) diff --git a/src/fastmcp/server/plugins/code_mode/transform.py b/src/fastmcp/server/plugins/code_mode/transform.py new file mode 100644 index 000000000..2ab7f4b48 --- /dev/null +++ b/src/fastmcp/server/plugins/code_mode/transform.py @@ -0,0 +1,186 @@ +"""Low-level transform that powers the CodeMode plugin. + +`CodeModeTransform` replaces the tool catalog with two classes of +meta-tool: configurable **discovery tools** (search, get_schema, etc.) +that let the LLM explore what's available, and a single **execute tool** +that runs LLM-generated Python in a sandbox with `call_tool(...)` +available in scope. + +Most users should configure CodeMode through the `CodeMode` plugin +(`fastmcp.server.plugins.code_mode`). The transform is exposed for +advanced composition — users who want to stack it with other transforms +directly or embed it in a custom plugin. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Annotated, Any + +from mcp.types import TextContent +from pydantic import Field + +from fastmcp.exceptions import NotFoundError +from fastmcp.server.context import Context +from fastmcp.server.plugins.code_mode.discovery import ( + DiscoveryToolFactory, + GetSchemas, + Search, +) +from fastmcp.server.plugins.code_mode.sandbox import ( + MontySandboxProvider, + SandboxProvider, +) +from fastmcp.server.transforms import GetToolNext +from fastmcp.server.transforms.catalog import CatalogTransform +from fastmcp.tools.base import Tool, ToolResult +from fastmcp.utilities.versions import VersionSpec + + +def _unwrap_tool_result(result: ToolResult) -> dict[str, Any] | str: + """Convert a ToolResult for use in the sandbox. + + - Output schema present → structured_content dict (matches the schema) + - Otherwise → concatenated text content as a string + """ + if result.structured_content is not None: + return result.structured_content + + parts: list[str] = [] + for content in result.content: + if isinstance(content, TextContent): + parts.append(content.text) + else: + parts.append(str(content)) + return "\n".join(parts) + + +def _default_discovery_tools() -> list[DiscoveryToolFactory]: + return [Search(), GetSchemas()] + + +class CodeModeTransform(CatalogTransform): + """Transform that collapses all tools into discovery + execute meta-tools. + + Discovery tools are composable via the `discovery_tools` parameter. + Each is a callable that receives catalog access and returns a `Tool`. + By default, `Search` and `GetSchemas` are included for progressive + disclosure: search finds candidates, get_schema retrieves parameter + details, and execute runs code. + + The `execute` tool is always present and provides a sandboxed Python + environment with `call_tool(name, params)` in scope. + """ + + def __init__( + self, + *, + sandbox_provider: SandboxProvider | None = None, + discovery_tools: list[DiscoveryToolFactory] | None = None, + execute_tool_name: str = "execute", + execute_description: str | None = None, + ) -> None: + super().__init__() + self.execute_tool_name = execute_tool_name + self.execute_description = execute_description + self.sandbox_provider = sandbox_provider or MontySandboxProvider() + + self._discovery_factories = ( + discovery_tools + if discovery_tools is not None + else _default_discovery_tools() + ) + self._built_discovery_tools: list[Tool] | None = None + self._cached_execute_tool: Tool | None = None + + def _build_discovery_tools(self) -> list[Tool]: + if self._built_discovery_tools is None: + tools = [ + factory(self.get_tool_catalog) for factory in self._discovery_factories + ] + names = {t.name for t in tools} + if self.execute_tool_name in names: + raise ValueError( + f"Discovery tool name '{self.execute_tool_name}' " + f"collides with execute_tool_name." + ) + if len(names) != len(tools): + raise ValueError("Discovery tools must have unique names.") + self._built_discovery_tools = tools + return self._built_discovery_tools + + async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: + return [*self._build_discovery_tools(), self._get_execute_tool()] + + async def get_tool( + self, + name: str, + call_next: GetToolNext, + *, + version: VersionSpec | None = None, + ) -> Tool | None: + for tool in self._build_discovery_tools(): + if tool.name == name: + return tool + if name == self.execute_tool_name: + return self._get_execute_tool() + return await call_next(name, version=version) + + def _build_execute_description(self) -> str: + if self.execute_description is not None: + return self.execute_description + + return ( + "Chain `await call_tool(...)` calls in one Python block; prefer returning the final answer from a single block.\n" + "Use `return` to produce output.\n" + "Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope." + ) + + @staticmethod + def _find_tool(name: str, tools: Sequence[Tool]) -> Tool | None: + """Find a tool by name from a pre-fetched list.""" + for tool in tools: + if tool.name == name: + return tool + return None + + def _get_execute_tool(self) -> Tool: + if self._cached_execute_tool is None: + self._cached_execute_tool = self._make_execute_tool() + return self._cached_execute_tool + + def _make_execute_tool(self) -> Tool: + transform = self + + async def execute( + code: Annotated[ + str, + Field( + description=( + "Python async code to execute tool calls via call_tool(name, arguments)" + ) + ), + ], + ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] + ) -> Any: + """Execute tool calls using Python code.""" + + async def call_tool(tool_name: str, params: dict[str, Any]) -> Any: + backend_tools = await transform.get_tool_catalog(ctx) + tool = transform._find_tool(tool_name, backend_tools) + if tool is None: + raise NotFoundError(f"Unknown tool: {tool_name}") + + result = await ctx.fastmcp.call_tool(tool.name, params) + return _unwrap_tool_result(result) + + return await transform.sandbox_provider.run( + code, + external_functions={"call_tool": call_tool}, + ) + + return Tool.from_function( + fn=execute, + name=self.execute_tool_name, + description=self._build_execute_description(), + ) diff --git a/tests/experimental/transforms/test_code_mode.py b/tests/server/plugins/test_code_mode.py similarity index 89% rename from tests/experimental/transforms/test_code_mode.py rename to tests/server/plugins/test_code_mode.py index 6eb680eca..690ce4627 100644 --- a/tests/experimental/transforms/test_code_mode.py +++ b/tests/server/plugins/test_code_mode.py @@ -7,15 +7,15 @@ from mcp.types import ImageContent, TextContent from fastmcp import Client, FastMCP from fastmcp.exceptions import ToolError -from fastmcp.experimental.transforms.code_mode import ( - CodeMode, +from fastmcp.server.context import Context +from fastmcp.server.plugins.code_mode import ( GetSchemas, GetToolCatalog, MontySandboxProvider, Search, - _ensure_async, ) -from fastmcp.server.context import Context +from fastmcp.server.plugins.code_mode.sandbox import _ensure_async +from fastmcp.server.plugins.code_mode.transform import CodeModeTransform from fastmcp.tools.base import Tool, ToolResult @@ -105,7 +105,7 @@ async def test_code_mode_default_tools() -> None: def ping() -> str: return "pong" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) listed_tools = await mcp.list_tools(run_middleware=False) assert {tool.name for tool in listed_tools} == {"search", "get_schema", "execute"} @@ -125,7 +125,7 @@ async def test_code_mode_search_returns_lightweight_results() -> None: """Say hello to someone.""" return f"Hello, {name}!" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "square number"}) text = _unwrap_string_result(result) @@ -144,7 +144,7 @@ async def test_code_mode_get_schema_brief() -> None: """Compute the square of a number.""" return x * x - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool( mcp, "get_schema", {"tools": ["square"], "detail": "brief"} @@ -165,7 +165,7 @@ async def test_code_mode_get_schema_detailed() -> None: """Compute the square of a number.""" return x * x - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool( mcp, "get_schema", {"tools": ["square"], "detail": "detailed"} @@ -186,7 +186,7 @@ async def test_code_mode_get_schema_full() -> None: """Compute the square of a number.""" return x * x - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "get_schema", {"tools": ["square"], "detail": "full"}) text = _unwrap_string_result(result) @@ -205,7 +205,7 @@ async def test_code_mode_get_schema_default_is_detailed() -> None: """Compute the square of a number.""" return x * x - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "get_schema", {"tools": ["square"]}) text = _unwrap_string_result(result) @@ -221,7 +221,7 @@ async def test_code_mode_get_schema_not_found() -> None: def ping() -> str: return "pong" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "get_schema", {"tools": ["nonexistent"]}) text = _unwrap_string_result(result) @@ -238,7 +238,7 @@ async def test_code_mode_get_schema_partial_match() -> None: """Compute the square.""" return x * x - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "get_schema", {"tools": ["square", "nonexistent"]}) text = _unwrap_string_result(result) @@ -254,7 +254,7 @@ async def test_code_mode_execute_works() -> None: def add(x: int, y: int) -> int: return x + y - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool( mcp, "execute", {"code": "return await call_tool('add', {'x': 2, 'y': 3})"} @@ -275,7 +275,7 @@ async def test_code_mode_custom_execute_name() -> None: return "pong" mcp.add_transform( - CodeMode( + CodeModeTransform( sandbox_provider=_UnsafeTestSandboxProvider(), execute_tool_name="run_code", ) @@ -295,7 +295,7 @@ async def test_code_mode_custom_execute_description() -> None: return "pong" mcp.add_transform( - CodeMode( + CodeModeTransform( sandbox_provider=_UnsafeTestSandboxProvider(), execute_description="Custom execute description", ) @@ -313,7 +313,7 @@ async def test_code_mode_default_execute_description() -> None: def ping() -> str: return "pong" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) listed = await mcp.list_tools(run_middleware=False) by_name = {t.name: t for t in listed} @@ -341,7 +341,7 @@ async def test_code_mode_no_discovery_tools() -> None: return "pong" mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -371,7 +371,7 @@ async def test_code_mode_custom_discovery_tool_function() -> None: return Tool.from_function(fn=list_tools, name="list_all") mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[list_all], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -394,7 +394,7 @@ async def test_code_mode_search_detailed() -> None: """Compute the square.""" return x * x - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "square", "detail": "detailed"}) text = _unwrap_string_result(result) @@ -414,7 +414,7 @@ async def test_code_mode_search_tool_full_detail() -> None: return x * x mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[Search(default_detail="full")], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -437,7 +437,7 @@ async def test_code_mode_custom_search_tool_name() -> None: return "pong" mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[ Search(name="find"), GetSchemas(name="describe"), @@ -452,7 +452,7 @@ async def test_code_mode_custom_search_tool_name() -> None: def test_code_mode_rejects_discovery_execute_name_collision() -> None: """CodeMode raises ValueError when a discovery tool collides with execute.""" - cm = CodeMode( + cm = CodeModeTransform( discovery_tools=[Search(name="execute")], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -462,7 +462,7 @@ def test_code_mode_rejects_discovery_execute_name_collision() -> None: def test_code_mode_rejects_duplicate_discovery_names() -> None: """CodeMode raises ValueError when discovery tools have duplicate names.""" - cm = CodeMode( + cm = CodeModeTransform( discovery_tools=[Search(name="search"), Search(name="search")], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -483,7 +483,7 @@ async def test_code_mode_execute_respects_disabled_tool_visibility() -> None: return "nope" mcp.disable(names={"secret"}, components={"tool"}) - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) with pytest.raises(ToolError, match=r"Unknown tool"): await _run_tool( @@ -500,7 +500,7 @@ async def test_code_mode_search_respects_disabled_tool_visibility() -> None: return "nope" mcp.disable(names={"secret"}, components={"tool"}) - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "secret"}) text = _unwrap_string_result(result) @@ -521,7 +521,7 @@ async def test_code_mode_execute_sees_mid_run_visibility_changes() -> None: return "secret-ok" mcp.disable(names={"secret"}, components={"tool"}) - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) async with Client(mcp) as client: result = await client.call_tool( @@ -540,7 +540,7 @@ async def test_code_mode_execute_respects_tool_auth() -> None: def protected() -> str: return "nope" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) with pytest.raises(ToolError, match=r"Unknown tool"): await _run_tool( @@ -556,7 +556,7 @@ async def test_code_mode_search_respects_tool_auth() -> None: """A protected tool.""" return "nope" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "protected"}) text = _unwrap_string_result(result) @@ -575,7 +575,7 @@ async def test_code_mode_shadows_colliding_tool_names() -> None: def ping() -> str: return "pong" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) tools = await mcp.list_tools(run_middleware=False) tool_names = {t.name for t in tools} @@ -600,7 +600,7 @@ async def test_code_mode_get_tool_returns_meta_tools_and_passes_through() -> Non def ping() -> str: return "pong" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) search_tool = await mcp.get_tool("search") assert search_tool is not None @@ -631,7 +631,7 @@ async def test_code_mode_execute_non_text_content_stringified() -> None: def image_tool() -> ImageContent: return ImageContent(type="image", data="base64data", mimeType="image/png") - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool( mcp, "execute", {"code": "return await call_tool('image_tool', {})"} @@ -653,7 +653,7 @@ async def test_code_mode_execute_multi_tool_chaining() -> None: def add_one(x: int) -> int: return x + 1 - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool( mcp, @@ -676,7 +676,7 @@ async def test_code_mode_sandbox_error_surfaces_as_tool_error() -> None: def ping() -> str: return "pong" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) with pytest.raises(ToolError): await _run_tool(mcp, "execute", {"code": "raise ValueError('boom')"}) diff --git a/tests/experimental/transforms/test_code_mode_discovery.py b/tests/server/plugins/test_code_mode_discovery.py similarity index 92% rename from tests/experimental/transforms/test_code_mode_discovery.py rename to tests/server/plugins/test_code_mode_discovery.py index f49e32a08..efe8e9b55 100644 --- a/tests/experimental/transforms/test_code_mode_discovery.py +++ b/tests/server/plugins/test_code_mode_discovery.py @@ -4,13 +4,13 @@ from typing import Any from mcp.types import TextContent from fastmcp import FastMCP -from fastmcp.experimental.transforms.code_mode import ( - CodeMode, +from fastmcp.server.plugins.code_mode import ( GetTags, ListTools, Search, - _ensure_async, ) +from fastmcp.server.plugins.code_mode.sandbox import _ensure_async +from fastmcp.server.plugins.code_mode.transform import CodeModeTransform from fastmcp.tools.base import ToolResult @@ -104,7 +104,7 @@ async def test_categories_brief_shows_tag_counts() -> None: return f"Hello, {name}!" mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[GetTags()], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -130,7 +130,7 @@ async def test_categories_full_lists_tools_per_tag() -> None: return f"Hello, {name}!" mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[GetTags(default_detail="full")], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -156,7 +156,7 @@ async def test_categories_includes_untagged() -> None: return "pong" mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[GetTags()], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -176,7 +176,7 @@ async def test_categories_tool_in_multiple_tags() -> None: return x + y mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[GetTags(default_detail="full")], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -200,7 +200,7 @@ async def test_categories_detail_override_per_call() -> None: return x + y mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[GetTags()], # default_detail="brief" sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -219,7 +219,7 @@ async def test_get_tags_empty_catalog() -> None: mcp.disable(names={"ping"}, components={"tool"}) mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[GetTags()], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -248,7 +248,7 @@ async def test_search_with_tags_filter() -> None: """Say hello.""" return f"Hello, {name}!" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "add hello", "tags": ["math"]}) text = _unwrap_string_result(result) @@ -264,7 +264,7 @@ async def test_search_with_tags_filter_no_matches() -> None: """Add two numbers.""" return x + y - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "add", "tags": ["nonexistent"]}) text = _unwrap_string_result(result) @@ -285,7 +285,7 @@ async def test_search_without_tags_returns_all() -> None: """Say hello.""" return f"Hello, {name}!" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "add hello"}) text = _unwrap_string_result(result) @@ -307,7 +307,7 @@ async def test_search_with_untagged_filter() -> None: """Ping.""" return "pong" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "ping add", "tags": ["untagged"]}) text = _unwrap_string_result(result) @@ -325,7 +325,7 @@ async def test_search_default_detail_detailed_skips_get_schema() -> None: return x * x mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[Search(default_detail="detailed")], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -346,7 +346,7 @@ async def test_search_full_detail_empty_results_returns_json() -> None: def add(x: int, y: int) -> int: return x + y - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool( mcp, @@ -366,7 +366,7 @@ async def test_get_schema_empty_tools_list() -> None: def ping() -> str: return "pong" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "get_schema", {"tools": []}) text = _unwrap_string_result(result) @@ -382,7 +382,7 @@ async def test_get_schema_full_partial_match_returns_valid_json() -> None: """Compute the square.""" return x * x - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool( mcp, "get_schema", {"tools": ["square", "nonexistent"], "detail": "full"} @@ -418,7 +418,7 @@ async def test_search_shows_catalog_size_when_results_are_subset() -> None: """Say hello.""" return f"Hello, {name}!" - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "add numbers"}) text = _unwrap_string_result(result) @@ -435,7 +435,7 @@ async def test_search_omits_annotation_when_all_tools_returned() -> None: """Add two numbers.""" return x + y - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "add numbers"}) text = _unwrap_string_result(result) @@ -466,7 +466,7 @@ async def test_search_limit_caps_results() -> None: """Multiply numbers.""" return x * y - mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + mcp.add_transform(CodeModeTransform(sandbox_provider=_UnsafeTestSandboxProvider())) result = await _run_tool(mcp, "search", {"query": "numbers", "limit": 1}) text = _unwrap_string_result(result) @@ -496,7 +496,7 @@ async def test_search_default_limit_from_constructor() -> None: return "c" mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[Search(default_limit=2)], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -527,7 +527,7 @@ async def test_list_tools_brief() -> None: return x * y mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[ListTools()], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -552,7 +552,7 @@ async def test_list_tools_detailed() -> None: return x * x mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[ListTools(default_detail="detailed")], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -575,7 +575,7 @@ async def test_list_tools_full_returns_json() -> None: return "pong" mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[ListTools()], sandbox_provider=_UnsafeTestSandboxProvider(), ) @@ -593,7 +593,7 @@ async def test_list_tools_empty_catalog() -> None: mcp = FastMCP("ListTools Empty") mcp.add_transform( - CodeMode( + CodeModeTransform( discovery_tools=[ListTools()], sandbox_provider=_UnsafeTestSandboxProvider(), ) diff --git a/tests/server/plugins/test_code_mode_plugin.py b/tests/server/plugins/test_code_mode_plugin.py new file mode 100644 index 000000000..b06ba869f --- /dev/null +++ b/tests/server/plugins/test_code_mode_plugin.py @@ -0,0 +1,105 @@ +"""Tests for the CodeMode plugin wrapper. + +Transform behavior (what `CodeModeTransform` does to the catalog, how +discovery tools render, sandbox execution, etc.) is covered by +`test_code_mode.py` and `test_code_mode_discovery.py`. This file only +covers the plugin layer itself — config validation, meta derivation, +dict-config coercion, and the deprecation shim at the old import path. +""" + +from __future__ import annotations + +import warnings +from typing import Any + +import pytest +from pydantic import ValidationError + +from fastmcp import FastMCP +from fastmcp.server.plugins.code_mode import CodeMode, CodeModeConfig + + +class _NoopSandbox: + async def run( + self, + code: str, + *, + inputs: dict[str, Any] | None = None, + external_functions: dict[str, Any] | None = None, + ) -> Any: + return None + + +class TestCodeModeConfig: + def test_config_generic_binding(self): + """`Plugin[CodeModeConfig]` binds CodeModeConfig as the validated config type.""" + assert CodeMode._config_cls is CodeModeConfig + + def test_dict_config_accepted(self): + """Dict config works for loading from JSON/YAML.""" + plugin = CodeMode({"execute_tool_name": "go"}) + assert plugin.config.execute_tool_name == "go" + + def test_unknown_sandbox_rejected(self): + with pytest.raises((ValidationError, Exception), match="sandbox"): + CodeModeConfig(sandbox="docker") # ty: ignore[invalid-argument-type] + + def test_unknown_config_key_rejected(self): + with pytest.raises((ValidationError, Exception), match="forbid|extra"): + CodeModeConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + + def test_default_meta(self): + """CodeMode uses Plugin's auto-derived meta: kebab-cased class + name, no independent version (bundled first-party plugin).""" + assert CodeMode.meta.name == "code-mode" + assert CodeMode.meta.version is None + + +class TestDeprecationShim: + """The old `fastmcp.experimental.transforms.code_mode` path still works but warns.""" + + def test_old_package_import_emits_deprecation_warning(self): + import importlib + import sys + + from fastmcp.exceptions import FastMCPDeprecationWarning + + sys.modules.pop("fastmcp.experimental.transforms.code_mode", None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.import_module("fastmcp.experimental.transforms.code_mode") + + fastmcp_deprecations = [ + w for w in caught if issubclass(w.category, FastMCPDeprecationWarning) + ] + assert any( + "plugins.code_mode" in str(w.message) for w in fastmcp_deprecations + ), ( + f"expected FastMCPDeprecationWarning pointing at plugins.code_mode, " + f"got {[(w.category.__name__, str(w.message)) for w in caught]}" + ) + + async def test_legacy_add_transform_pattern_still_works(self): + """End-to-end: old `add_transform(CodeMode(...))` code keeps + working. The point of the shim is that this doesn't break — the + identity-check test alone wouldn't catch a regression where + `CodeMode` at the old path drifted to the plugin class.""" + from fastmcp.exceptions import FastMCPDeprecationWarning + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FastMCPDeprecationWarning) + from fastmcp.experimental.transforms.code_mode import ( + CodeMode as OldCodeMode, + ) + + mcp = FastMCP("legacy") + + @mcp.tool + def ping() -> str: + return "pong" + + mcp.add_transform(OldCodeMode(sandbox_provider=_NoopSandbox())) + + tools = await mcp.list_tools(run_middleware=False) + assert {t.name for t in tools} == {"search", "get_schema", "execute"} diff --git a/tests/experimental/transforms/test_code_mode_serialization.py b/tests/server/plugins/test_code_mode_serialization.py similarity index 100% rename from tests/experimental/transforms/test_code_mode_serialization.py rename to tests/server/plugins/test_code_mode_serialization.py From 03f4a90e60c2953b1c468e0b85a2f40998b5219f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:29:16 -0400 Subject: [PATCH 11/17] Convert prompts-as-tools and resources-as-tools to plugins (#4012) --- docs/servers/transforms/prompts-as-tools.mdx | 9 +- .../servers/transforms/resources-as-tools.mdx | 9 +- examples/prompts_as_tools/server.py | 10 +- examples/resources_as_tools/server.py | 10 +- .../plugins/prompts_as_tools/__init__.py | 17 ++ .../server/plugins/prompts_as_tools/plugin.py | 41 ++++ .../plugins/prompts_as_tools/transform.py | 157 ++++++++++++++ .../plugins/resources_as_tools/__init__.py | 17 ++ .../plugins/resources_as_tools/plugin.py | 43 ++++ .../plugins/resources_as_tools/transform.py | 168 ++++++++++++++ src/fastmcp/server/transforms/__init__.py | 24 +- .../server/transforms/prompts_as_tools.py | 192 +++------------- .../server/transforms/resources_as_tools.py | 205 +++--------------- .../test_prompts_as_tools.py | 0 .../plugins/test_prompts_as_tools_plugin.py | 125 +++++++++++ .../test_resources_as_tools.py | 0 .../plugins/test_resources_as_tools_plugin.py | 91 ++++++++ 17 files changed, 765 insertions(+), 353 deletions(-) create mode 100644 src/fastmcp/server/plugins/prompts_as_tools/__init__.py create mode 100644 src/fastmcp/server/plugins/prompts_as_tools/plugin.py create mode 100644 src/fastmcp/server/plugins/prompts_as_tools/transform.py create mode 100644 src/fastmcp/server/plugins/resources_as_tools/__init__.py create mode 100644 src/fastmcp/server/plugins/resources_as_tools/plugin.py create mode 100644 src/fastmcp/server/plugins/resources_as_tools/transform.py rename tests/server/{transforms => plugins}/test_prompts_as_tools.py (100%) create mode 100644 tests/server/plugins/test_prompts_as_tools_plugin.py rename tests/server/{transforms => plugins}/test_resources_as_tools.py (100%) create mode 100644 tests/server/plugins/test_resources_as_tools_plugin.py diff --git a/docs/servers/transforms/prompts-as-tools.mdx b/docs/servers/transforms/prompts-as-tools.mdx index 6a9ab1b47..5e10d4535 100644 --- a/docs/servers/transforms/prompts-as-tools.mdx +++ b/docs/servers/transforms/prompts-as-tools.mdx @@ -24,14 +24,14 @@ This means any client that can call tools can now access prompts, even if the cl Pass your FastMCP server to `PromptsAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to prompt operations automatically, exactly as it would for direct `prompts/get` calls. -`PromptsAsTools` (and `ResourcesAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of prompts, create a dedicated FastMCP server for those prompts and apply the transform there. +`PromptsAsTools` is a plugin — register it on a FastMCP server (not a raw Provider). The generated tools call back into the server's middleware chain at runtime. If you want to expose only a subset of prompts, create a dedicated FastMCP server for those prompts and register the plugin there. ```python from fastmcp import FastMCP -from fastmcp.server.transforms import PromptsAsTools +from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools -mcp = FastMCP("My Server") +mcp = FastMCP("My Server", plugins=[PromptsAsTools()]) @mcp.prompt def analyze_code(code: str, language: str = "python") -> str: @@ -42,9 +42,6 @@ def analyze_code(code: str, language: str = "python") -> str: def explain_concept(concept: str) -> str: """Explain a programming concept.""" return f"Explain: {concept}" - -# Add the transform - creates list_prompts and get_prompt tools -mcp.add_transform(PromptsAsTools(mcp)) ``` Clients now see three items: whatever tools you defined directly, plus `list_prompts` and `get_prompt`. diff --git a/docs/servers/transforms/resources-as-tools.mdx b/docs/servers/transforms/resources-as-tools.mdx index b79980dcc..5f3b5ebd1 100644 --- a/docs/servers/transforms/resources-as-tools.mdx +++ b/docs/servers/transforms/resources-as-tools.mdx @@ -24,14 +24,14 @@ This means any client that can call tools can now access resources, even if the Pass your FastMCP server to `ResourcesAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to resource operations automatically, exactly as it would for direct `resources/read` calls. -`ResourcesAsTools` (and `PromptsAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of resources, create a dedicated FastMCP server for those resources and apply the transform there. +`ResourcesAsTools` is a plugin — register it on a FastMCP server (not a raw Provider). The generated tools call back into the server's middleware chain at runtime. If you want to expose only a subset of resources, create a dedicated FastMCP server for those resources and register the plugin there. ```python from fastmcp import FastMCP -from fastmcp.server.transforms import ResourcesAsTools +from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools -mcp = FastMCP("My Server") +mcp = FastMCP("My Server", plugins=[ResourcesAsTools()]) @mcp.resource("config://app") def app_config() -> str: @@ -42,9 +42,6 @@ def app_config() -> str: def user_profile(user_id: str) -> str: """Get a user's profile by ID.""" return f'{{"user_id": "{user_id}", "name": "User {user_id}"}}' - -# Add the transform - creates list_resources and read_resource tools -mcp.add_transform(ResourcesAsTools(mcp)) ``` Clients now see three tools: whatever tools you defined directly, plus `list_resources` and `read_resource`. diff --git a/examples/prompts_as_tools/server.py b/examples/prompts_as_tools/server.py index 912ef0331..bc642aa34 100644 --- a/examples/prompts_as_tools/server.py +++ b/examples/prompts_as_tools/server.py @@ -1,4 +1,4 @@ -"""Example: Expose prompts as tools using PromptsAsTools transform. +"""Example: Expose prompts as tools using the PromptsAsTools plugin. This example shows how to use PromptsAsTools to make prompts accessible to clients that only support tools (not the prompts protocol). @@ -8,9 +8,9 @@ Run with: """ from fastmcp import FastMCP -from fastmcp.server.transforms import PromptsAsTools +from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools -mcp = FastMCP("Prompt Tools Demo") +mcp = FastMCP("Prompt Tools Demo", plugins=[PromptsAsTools()]) # Simple prompt without arguments @@ -78,8 +78,8 @@ Please provide: """ -# Add the transform - this creates list_prompts and get_prompt tools -mcp.add_transform(PromptsAsTools(mcp)) +# PromptsAsTools (registered at construction above) adds list_prompts +# and get_prompt synthetic tools so tools-only clients can drive prompts. if __name__ == "__main__": diff --git a/examples/resources_as_tools/server.py b/examples/resources_as_tools/server.py index 25141591e..a261a7bbb 100644 --- a/examples/resources_as_tools/server.py +++ b/examples/resources_as_tools/server.py @@ -1,4 +1,4 @@ -"""Example: Expose resources as tools using ResourcesAsTools transform. +"""Example: Expose resources as tools using the ResourcesAsTools plugin. This example shows how to use ResourcesAsTools to make resources accessible to clients that only support tools (not the resources protocol). @@ -8,9 +8,9 @@ Run with: """ from fastmcp import FastMCP -from fastmcp.server.transforms import ResourcesAsTools +from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools -mcp = FastMCP("Resource Tools Demo") +mcp = FastMCP("Resource Tools Demo", plugins=[ResourcesAsTools()]) # Static resource - has a fixed URI @@ -57,8 +57,8 @@ def read_file(directory: str, filename: str) -> str: return f"Contents of {directory}/{filename}" -# Add the transform - this creates list_resources and read_resource tools -mcp.add_transform(ResourcesAsTools(mcp)) +# ResourcesAsTools (registered at construction above) adds list_resources +# and read_resource synthetic tools so tools-only clients can drive resources. if __name__ == "__main__": diff --git a/src/fastmcp/server/plugins/prompts_as_tools/__init__.py b/src/fastmcp/server/plugins/prompts_as_tools/__init__.py new file mode 100644 index 000000000..d8f677e3c --- /dev/null +++ b/src/fastmcp/server/plugins/prompts_as_tools/__init__.py @@ -0,0 +1,17 @@ +"""PromptsAsTools plugin — expose MCP prompts as callable tools. + + from fastmcp import FastMCP + from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools + + mcp = FastMCP("Server", plugins=[PromptsAsTools()]) + +The low-level `PromptsAsToolsTransform` lives in `.transform` for +advanced users who want to compose it directly with other transforms. +""" + +from fastmcp.server.plugins.prompts_as_tools.plugin import ( + PromptsAsTools, + PromptsAsToolsConfig, +) + +__all__ = ["PromptsAsTools", "PromptsAsToolsConfig"] diff --git a/src/fastmcp/server/plugins/prompts_as_tools/plugin.py b/src/fastmcp/server/plugins/prompts_as_tools/plugin.py new file mode 100644 index 000000000..a4b192a4c --- /dev/null +++ b/src/fastmcp/server/plugins/prompts_as_tools/plugin.py @@ -0,0 +1,41 @@ +"""PromptsAsTools plugin: expose MCP prompts as callable tools.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from fastmcp.server.plugins.base import Plugin +from fastmcp.server.plugins.prompts_as_tools.transform import PromptsAsToolsTransform +from fastmcp.server.transforms import Transform + + +class PromptsAsToolsConfig(BaseModel): + """Config model for the `PromptsAsTools` plugin. + + Currently empty — included so plugin configs loaded from JSON/YAML + can still reference this plugin by name, and so future + per-deployment tool-name overrides have somewhere to land. + """ + + model_config = ConfigDict(extra="forbid") + + +class PromptsAsTools(Plugin[PromptsAsToolsConfig]): + """Append `list_prompts` and `get_prompt` synthetic tools to the catalog. + + For clients that only speak the tools protocol, this plugin exposes + prompt discovery and rendering as regular tool calls. The generated + tools route through `ctx.fastmcp` at request time, so middleware, + auth, and visibility apply automatically. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools + + mcp = FastMCP("Server", plugins=[PromptsAsTools()]) + ``` + """ + + def transforms(self) -> list[Transform]: + return [PromptsAsToolsTransform()] diff --git a/src/fastmcp/server/plugins/prompts_as_tools/transform.py b/src/fastmcp/server/plugins/prompts_as_tools/transform.py new file mode 100644 index 000000000..00617362e --- /dev/null +++ b/src/fastmcp/server/plugins/prompts_as_tools/transform.py @@ -0,0 +1,157 @@ +"""Low-level transform that powers the PromptsAsTools plugin. + +`PromptsAsToolsTransform` appends two synthetic tools — `list_prompts` +and `get_prompt` — to the tool catalog, so clients that only speak the +tools protocol can still drive prompt discovery and rendering. Both +generated tools route through `get_context().fastmcp` at request time, +so middleware, auth, and visibility all apply exactly as they would for +direct `prompts/*` calls. + +Most users should configure this through the `PromptsAsTools` plugin +(`fastmcp.server.plugins.prompts_as_tools`). The transform is exposed +for advanced composition and for backcompat with the old +`fastmcp.server.transforms.prompts_as_tools` path. +""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from typing import TYPE_CHECKING, Annotated, Any + +from mcp.types import TextContent + +from fastmcp.server.dependencies import get_context +from fastmcp.server.transforms import GetToolNext, Transform +from fastmcp.tools.base import Tool +from fastmcp.utilities.versions import VersionSpec + +if TYPE_CHECKING: + from fastmcp.server.providers.base import Provider + + +class PromptsAsToolsTransform(Transform): + """Transform that adds `list_prompts` and `get_prompt` synthetic tools. + + The generated tools call back into the server via `ctx.fastmcp` at + request time, so server middleware (auth, visibility, rate limiting) + applies automatically. + + The `provider` argument exists purely for intent — if passed, it + must be a FastMCP server instance (raw providers don't expose an + `add_transform` path anyway). The plugin wrapper constructs this + transform without a provider. + + Example: + ```python + mcp = FastMCP("Server") + mcp.add_transform(PromptsAsToolsTransform(mcp)) + ``` + """ + + def __init__(self, provider: Provider | None = None) -> None: + if provider is not None: + from fastmcp.server.server import FastMCP + + if not isinstance(provider, FastMCP): + raise TypeError( + "PromptsAsToolsTransform accepts a FastMCP server instance, " + f"not a {type(provider).__name__}. The generated tools route " + "through the server's middleware chain at runtime for auth " + "and visibility. Pass your FastMCP server, or omit the " + "argument entirely when using the plugin wrapper." + ) + self._provider = provider + + def __repr__(self) -> str: + return f"PromptsAsToolsTransform({self._provider!r})" + + async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: + return [ + *tools, + self._make_list_prompts_tool(), + self._make_get_prompt_tool(), + ] + + async def get_tool( + self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None + ) -> Tool | None: + if name == "list_prompts": + return self._make_list_prompts_tool() + if name == "get_prompt": + return self._make_get_prompt_tool() + return await call_next(name, version=version) + + def _make_list_prompts_tool(self) -> Tool: + async def list_prompts() -> str: + """List all available prompts. + + Returns JSON with prompt metadata including name, description, + and optional arguments. + """ + ctx = get_context() + prompts = await ctx.fastmcp.list_prompts() + + result: list[dict[str, Any]] = [] + for p in prompts: + result.append( # noqa: PERF401 + { + "name": p.name, + "description": p.description, + "arguments": [ + { + "name": arg.name, + "description": arg.description, + "required": arg.required, + } + for arg in (p.arguments or []) + ], + } + ) + + return json.dumps(result, indent=2) + + return Tool.from_function(fn=list_prompts) + + def _make_get_prompt_tool(self) -> Tool: + async def get_prompt( + name: Annotated[str, "The name of the prompt to get"], + arguments: Annotated[ + dict[str, Any] | None, + "Optional arguments for the prompt", + ] = None, + ) -> str: + """Get a prompt by name with optional arguments. + + Returns the rendered prompt as JSON with a messages array. + Arguments should be provided as a dict mapping argument names + to values. + """ + ctx = get_context() + result = await ctx.fastmcp.render_prompt(name, arguments=arguments or {}) + return _format_prompt_result(result) + + return Tool.from_function(fn=get_prompt) + + +def _format_prompt_result(result: Any) -> str: + """Format PromptResult for tool output. + + Returns JSON with the messages array. Preserves embedded resources + as structured JSON objects. + """ + messages = [] + for msg in result.messages: + if isinstance(msg.content, TextContent): + content = msg.content.text + else: + content = msg.content.model_dump(mode="json", exclude_none=True) + + messages.append( + { + "role": msg.role, + "content": content, + } + ) + + return json.dumps({"messages": messages}, indent=2) diff --git a/src/fastmcp/server/plugins/resources_as_tools/__init__.py b/src/fastmcp/server/plugins/resources_as_tools/__init__.py new file mode 100644 index 000000000..9198b6b6e --- /dev/null +++ b/src/fastmcp/server/plugins/resources_as_tools/__init__.py @@ -0,0 +1,17 @@ +"""ResourcesAsTools plugin — expose MCP resources as callable tools. + + from fastmcp import FastMCP + from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools + + mcp = FastMCP("Server", plugins=[ResourcesAsTools()]) + +The low-level `ResourcesAsToolsTransform` lives in `.transform` for +advanced users who want to compose it directly with other transforms. +""" + +from fastmcp.server.plugins.resources_as_tools.plugin import ( + ResourcesAsTools, + ResourcesAsToolsConfig, +) + +__all__ = ["ResourcesAsTools", "ResourcesAsToolsConfig"] diff --git a/src/fastmcp/server/plugins/resources_as_tools/plugin.py b/src/fastmcp/server/plugins/resources_as_tools/plugin.py new file mode 100644 index 000000000..c6948b793 --- /dev/null +++ b/src/fastmcp/server/plugins/resources_as_tools/plugin.py @@ -0,0 +1,43 @@ +"""ResourcesAsTools plugin: expose MCP resources as callable tools.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from fastmcp.server.plugins.base import Plugin +from fastmcp.server.plugins.resources_as_tools.transform import ( + ResourcesAsToolsTransform, +) +from fastmcp.server.transforms import Transform + + +class ResourcesAsToolsConfig(BaseModel): + """Config model for the `ResourcesAsTools` plugin. + + Currently empty — included so plugin configs loaded from JSON/YAML + can still reference this plugin by name, and so future + per-deployment tool-name overrides have somewhere to land. + """ + + model_config = ConfigDict(extra="forbid") + + +class ResourcesAsTools(Plugin[ResourcesAsToolsConfig]): + """Append `list_resources` and `read_resource` synthetic tools to the catalog. + + For clients that only speak the tools protocol, this plugin exposes + resource discovery and reads as regular tool calls. The generated + tools route through `ctx.fastmcp` at request time, so middleware, + auth, and visibility apply automatically. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools + + mcp = FastMCP("Server", plugins=[ResourcesAsTools()]) + ``` + """ + + def transforms(self) -> list[Transform]: + return [ResourcesAsToolsTransform()] diff --git a/src/fastmcp/server/plugins/resources_as_tools/transform.py b/src/fastmcp/server/plugins/resources_as_tools/transform.py new file mode 100644 index 000000000..b6262f2c4 --- /dev/null +++ b/src/fastmcp/server/plugins/resources_as_tools/transform.py @@ -0,0 +1,168 @@ +"""Low-level transform that powers the ResourcesAsTools plugin. + +`ResourcesAsToolsTransform` appends two synthetic tools — +`list_resources` and `read_resource` — to the tool catalog, so clients +that only speak the tools protocol can still drive resource discovery +and reads. Both generated tools route through `get_context().fastmcp` +at request time, so middleware, auth, and visibility all apply exactly +as they would for direct `resources/*` calls. + +Most users should configure this through the `ResourcesAsTools` plugin +(`fastmcp.server.plugins.resources_as_tools`). The transform is exposed +for advanced composition and for backcompat with the old +`fastmcp.server.transforms.resources_as_tools` path. +""" + +from __future__ import annotations + +import base64 +import json +from collections.abc import Sequence +from typing import TYPE_CHECKING, Annotated, Any + +from mcp.types import ToolAnnotations + +from fastmcp.server.dependencies import get_context +from fastmcp.server.transforms import GetToolNext, Transform +from fastmcp.tools.base import Tool +from fastmcp.utilities.versions import VersionSpec + +_DEFAULT_ANNOTATIONS = ToolAnnotations(readOnlyHint=True) + +if TYPE_CHECKING: + from fastmcp.server.providers.base import Provider + + +class ResourcesAsToolsTransform(Transform): + """Transform that adds `list_resources` and `read_resource` synthetic tools. + + The generated tools call back into the server via `ctx.fastmcp` at + request time, so server middleware (auth, visibility, rate limiting) + applies automatically. + + The `provider` argument exists purely for intent — if passed, it + must be a FastMCP server instance (raw providers don't expose an + `add_transform` path anyway). The plugin wrapper constructs this + transform without a provider. + + Example: + ```python + mcp = FastMCP("Server") + mcp.add_transform(ResourcesAsToolsTransform(mcp)) + ``` + """ + + def __init__(self, provider: Provider | None = None) -> None: + if provider is not None: + from fastmcp.server.server import FastMCP + + if not isinstance(provider, FastMCP): + raise TypeError( + "ResourcesAsToolsTransform accepts a FastMCP server instance, " + f"not a {type(provider).__name__}. The generated tools route " + "through the server's middleware chain at runtime for auth " + "and visibility. Pass your FastMCP server, or omit the " + "argument entirely when using the plugin wrapper." + ) + self._provider = provider + + def __repr__(self) -> str: + return f"ResourcesAsToolsTransform({self._provider!r})" + + async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: + return [ + *tools, + self._make_list_resources_tool(), + self._make_read_resource_tool(), + ] + + async def get_tool( + self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None + ) -> Tool | None: + if name == "list_resources": + return self._make_list_resources_tool() + if name == "read_resource": + return self._make_read_resource_tool() + return await call_next(name, version=version) + + def _make_list_resources_tool(self) -> Tool: + async def list_resources() -> str: + """List all available resources and resource templates. + + Returns JSON with resource metadata. Static resources have a + 'uri' field, while templates have a 'uri_template' field with + placeholders like {name}. + """ + ctx = get_context() + resources = await ctx.fastmcp.list_resources() + templates = await ctx.fastmcp.list_resource_templates() + + result: list[dict[str, Any]] = [] + + for r in resources: + result.append( # noqa: PERF401 + { + "uri": str(r.uri), + "name": r.name, + "description": r.description, + "mime_type": r.mime_type, + } + ) + + for t in templates: + result.append( # noqa: PERF401 + { + "uri_template": t.uri_template, + "name": t.name, + "description": t.description, + } + ) + + return json.dumps(result, indent=2) + + return Tool.from_function(fn=list_resources, annotations=_DEFAULT_ANNOTATIONS) + + def _make_read_resource_tool(self) -> Tool: + async def read_resource( + uri: Annotated[str, "The URI of the resource to read"], + ) -> str: + """Read a resource by its URI. + + For static resources, provide the exact URI. For templated + resources, provide the URI with template parameters filled in. + + Returns the resource content as a string. Binary content is + base64-encoded. + """ + ctx = get_context() + result = await ctx.fastmcp.read_resource(uri) + return _format_result(result) + + return Tool.from_function(fn=read_resource, annotations=_DEFAULT_ANNOTATIONS) + + +def _format_result(result: Any) -> str: + """Format ResourceResult for tool output. + + Single text content is returned as-is. Single binary content is + base64-encoded. Multiple contents are JSON-encoded. + """ + if len(result.contents) == 1: + content = result.contents[0].content + if isinstance(content, bytes): + return base64.b64encode(content).decode() + return content + + return json.dumps( + [ + { + "content": ( + c.content + if isinstance(c.content, str) + else base64.b64encode(c.content).decode() + ), + "mime_type": c.mime_type, + } + for c in result.contents + ] + ) diff --git a/src/fastmcp/server/transforms/__init__.py b/src/fastmcp/server/transforms/__init__.py index 411a2e0f8..15ea2f1a3 100644 --- a/src/fastmcp/server/transforms/__init__.py +++ b/src/fastmcp/server/transforms/__init__.py @@ -222,11 +222,31 @@ class Transform: # Re-export built-in transforms (must be after Transform class to avoid circular imports) from fastmcp.server.transforms.visibility import Visibility, is_enabled # noqa: E402 from fastmcp.server.transforms.namespace import Namespace # noqa: E402 -from fastmcp.server.transforms.prompts_as_tools import PromptsAsTools # noqa: E402 -from fastmcp.server.transforms.resources_as_tools import ResourcesAsTools # noqa: E402 from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402 from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402 + +# PromptsAsTools / ResourcesAsTools moved to `fastmcp.server.plugins.*`. +# Resolve lazily via `__getattr__` so importing anything else from this +# package doesn't load the plugin packages (which would cause a circular +# import through `fastmcp.server.plugins.base` → `fastmcp.server.providers` +# → back here). +def __getattr__(name: str): + if name == "PromptsAsTools": + from fastmcp.server.plugins.prompts_as_tools.transform import ( + PromptsAsToolsTransform, + ) + + return PromptsAsToolsTransform + if name == "ResourcesAsTools": + from fastmcp.server.plugins.resources_as_tools.transform import ( + ResourcesAsToolsTransform, + ) + + return ResourcesAsToolsTransform + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ "Namespace", "PromptsAsTools", diff --git a/src/fastmcp/server/transforms/prompts_as_tools.py b/src/fastmcp/server/transforms/prompts_as_tools.py index 2caa13c0f..77a3a7547 100644 --- a/src/fastmcp/server/transforms/prompts_as_tools.py +++ b/src/fastmcp/server/transforms/prompts_as_tools.py @@ -1,169 +1,43 @@ -"""Transform that exposes prompts as tools. +"""Deprecation shim — prompts-as-tools moved to `fastmcp.server.plugins.prompts_as_tools`. -This transform generates tools for listing and getting prompts, enabling -clients that only support tools to access prompt functionality. +The preferred API is now the `PromptsAsTools` plugin: -The generated tools route through `ctx.fastmcp` at runtime, so all server -middleware (auth, visibility, rate limiting, etc.) applies to prompt -operations exactly as it would for direct `prompts/get` calls. - -Example: - ```python from fastmcp import FastMCP - from fastmcp.server.transforms import PromptsAsTools + from fastmcp.server.plugins.prompts_as_tools import PromptsAsTools - mcp = FastMCP("Server") - mcp.add_transform(PromptsAsTools(mcp)) - # Now has list_prompts and get_prompt tools - ``` + mcp = FastMCP("Server", plugins=[PromptsAsTools()]) + +For backcompat, this module keeps `PromptsAsTools` bound to the +**transform** class (so existing `mcp.add_transform(PromptsAsTools(mcp))` +code keeps working). The transform is also exported under its new +canonical name, `PromptsAsToolsTransform`. + +This path issues a `FastMCPDeprecationWarning` on import — a +`DeprecationWarning` subclass that fastmcp enables by default (plain +`DeprecationWarning` is suppressed by CPython's default filter, so +users wouldn't see the notice). """ -from __future__ import annotations +import warnings -import json -from collections.abc import Sequence -from typing import TYPE_CHECKING, Annotated, Any +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.prompts_as_tools.transform import PromptsAsToolsTransform -from mcp.types import TextContent +# `PromptsAsTools` at this old path stays bound to the transform class, +# so `mcp.add_transform(PromptsAsTools(mcp))` keeps working. The new +# plugin class is at `fastmcp.server.plugins.prompts_as_tools.PromptsAsTools`. +PromptsAsTools = PromptsAsToolsTransform -from fastmcp.server.dependencies import get_context -from fastmcp.server.transforms import GetToolNext, Transform -from fastmcp.tools.base import Tool -from fastmcp.utilities.versions import VersionSpec +warnings.warn( + "fastmcp.server.transforms.prompts_as_tools has moved to " + "fastmcp.server.plugins.prompts_as_tools. Prefer the PromptsAsTools " + "plugin: `from fastmcp.server.plugins.prompts_as_tools import " + "PromptsAsTools` and pass it via `plugins=[PromptsAsTools()]`. At " + "this old path, `PromptsAsTools` remains the transform class (also " + "exported as `PromptsAsToolsTransform`) for backcompat. The old " + "import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, +) -if TYPE_CHECKING: - from fastmcp.server.providers.base import Provider - - -class PromptsAsTools(Transform): - """Transform that adds tools for listing and getting prompts. - - Generates two tools: - - `list_prompts`: Lists all prompts - - `get_prompt`: Gets a specific prompt with optional arguments - - The generated tools route through the server at runtime, so auth, - middleware, and visibility apply automatically. - - This transform should be applied to a FastMCP server instance, not - a raw Provider, because the generated tools need the server's - middleware chain for auth and visibility filtering. - - Example: - ```python - mcp = FastMCP("Server") - mcp.add_transform(PromptsAsTools(mcp)) - # Now has list_prompts and get_prompt tools - ``` - """ - - def __init__(self, provider: Provider) -> None: - from fastmcp.server.server import FastMCP - - if not isinstance(provider, FastMCP): - raise TypeError( - "PromptsAsTools requires a FastMCP server instance, not a" - f" {type(provider).__name__}. The generated tools route through" - " the server's middleware chain at runtime for auth and" - " visibility. Pass your FastMCP server: PromptsAsTools(mcp)" - ) - self._provider = provider - - def __repr__(self) -> str: - return f"PromptsAsTools({self._provider!r})" - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - """Add prompt tools to the tool list.""" - return [ - *tools, - self._make_list_prompts_tool(), - self._make_get_prompt_tool(), - ] - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name, including generated prompt tools.""" - if name == "list_prompts": - return self._make_list_prompts_tool() - if name == "get_prompt": - return self._make_get_prompt_tool() - return await call_next(name, version=version) - - def _make_list_prompts_tool(self) -> Tool: - """Create the list_prompts tool.""" - - async def list_prompts() -> str: - """List all available prompts. - - Returns JSON with prompt metadata including name, description, - and optional arguments. - """ - ctx = get_context() - prompts = await ctx.fastmcp.list_prompts() - - result: list[dict[str, Any]] = [] - for p in prompts: - result.append( # noqa: PERF401 - { - "name": p.name, - "description": p.description, - "arguments": [ - { - "name": arg.name, - "description": arg.description, - "required": arg.required, - } - for arg in (p.arguments or []) - ], - } - ) - - return json.dumps(result, indent=2) - - return Tool.from_function(fn=list_prompts) - - def _make_get_prompt_tool(self) -> Tool: - """Create the get_prompt tool.""" - - async def get_prompt( - name: Annotated[str, "The name of the prompt to get"], - arguments: Annotated[ - dict[str, Any] | None, - "Optional arguments for the prompt", - ] = None, - ) -> str: - """Get a prompt by name with optional arguments. - - Returns the rendered prompt as JSON with a messages array. - Arguments should be provided as a dict mapping argument names - to values. - """ - ctx = get_context() - result = await ctx.fastmcp.render_prompt(name, arguments=arguments or {}) - return _format_prompt_result(result) - - return Tool.from_function(fn=get_prompt) - - -def _format_prompt_result(result: Any) -> str: - """Format PromptResult for tool output. - - Returns JSON with the messages array. Preserves embedded resources - as structured JSON objects. - """ - messages = [] - for msg in result.messages: - if isinstance(msg.content, TextContent): - content = msg.content.text - else: - content = msg.content.model_dump(mode="json", exclude_none=True) - - messages.append( - { - "role": msg.role, - "content": content, - } - ) - - return json.dumps({"messages": messages}, indent=2) +__all__ = ["PromptsAsTools", "PromptsAsToolsTransform"] diff --git a/src/fastmcp/server/transforms/resources_as_tools.py b/src/fastmcp/server/transforms/resources_as_tools.py index 2b0350205..cdc164ed0 100644 --- a/src/fastmcp/server/transforms/resources_as_tools.py +++ b/src/fastmcp/server/transforms/resources_as_tools.py @@ -1,180 +1,45 @@ -"""Transform that exposes resources as tools. +"""Deprecation shim — resources-as-tools moved to `fastmcp.server.plugins.resources_as_tools`. -This transform generates tools for listing and reading resources, enabling -clients that only support tools to access resource functionality. +The preferred API is now the `ResourcesAsTools` plugin: -The generated tools route through `ctx.fastmcp` at runtime, so all server -middleware (auth, visibility, rate limiting, etc.) applies to resource -operations exactly as it would for direct `resources/read` calls. - -Example: - ```python from fastmcp import FastMCP - from fastmcp.server.transforms import ResourcesAsTools + from fastmcp.server.plugins.resources_as_tools import ResourcesAsTools - mcp = FastMCP("Server") - mcp.add_transform(ResourcesAsTools(mcp)) - # Now has list_resources and read_resource tools - ``` + mcp = FastMCP("Server", plugins=[ResourcesAsTools()]) + +For backcompat, this module keeps `ResourcesAsTools` bound to the +**transform** class (so existing `mcp.add_transform(ResourcesAsTools(mcp))` +code keeps working). The transform is also exported under its new +canonical name, `ResourcesAsToolsTransform`. + +This path issues a `FastMCPDeprecationWarning` on import — a +`DeprecationWarning` subclass that fastmcp enables by default (plain +`DeprecationWarning` is suppressed by CPython's default filter, so +users wouldn't see the notice). """ -from __future__ import annotations +import warnings -import base64 -import json -from collections.abc import Sequence -from typing import TYPE_CHECKING, Annotated, Any +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.resources_as_tools.transform import ( + ResourcesAsToolsTransform, +) -from mcp.types import ToolAnnotations +# `ResourcesAsTools` at this old path stays bound to the transform class, +# so `mcp.add_transform(ResourcesAsTools(mcp))` keeps working. The new +# plugin class is at `fastmcp.server.plugins.resources_as_tools.ResourcesAsTools`. +ResourcesAsTools = ResourcesAsToolsTransform -from fastmcp.server.dependencies import get_context -from fastmcp.server.transforms import GetToolNext, Transform -from fastmcp.tools.base import Tool -from fastmcp.utilities.versions import VersionSpec +warnings.warn( + "fastmcp.server.transforms.resources_as_tools has moved to " + "fastmcp.server.plugins.resources_as_tools. Prefer the " + "ResourcesAsTools plugin: `from fastmcp.server.plugins.resources_as_tools " + "import ResourcesAsTools` and pass it via `plugins=[ResourcesAsTools()]`. " + "At this old path, `ResourcesAsTools` remains the transform class " + "(also exported as `ResourcesAsToolsTransform`) for backcompat. The " + "old import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, +) -_DEFAULT_ANNOTATIONS = ToolAnnotations(readOnlyHint=True) - -if TYPE_CHECKING: - from fastmcp.server.providers.base import Provider - - -class ResourcesAsTools(Transform): - """Transform that adds tools for listing and reading resources. - - Generates two tools: - - `list_resources`: Lists all resources and templates - - `read_resource`: Reads a resource by URI - - The generated tools route through the server at runtime, so auth, - middleware, and visibility apply automatically. - - This transform should be applied to a FastMCP server instance, not - a raw Provider, because the generated tools need the server's - middleware chain for auth and visibility filtering. - - Example: - ```python - mcp = FastMCP("Server") - mcp.add_transform(ResourcesAsTools(mcp)) - # Now has list_resources and read_resource tools - ``` - """ - - def __init__(self, provider: Provider) -> None: - from fastmcp.server.server import FastMCP - - if not isinstance(provider, FastMCP): - raise TypeError( - "ResourcesAsTools requires a FastMCP server instance, not a" - f" {type(provider).__name__}. The generated tools route through" - " the server's middleware chain at runtime for auth and" - " visibility. Pass your FastMCP server: ResourcesAsTools(mcp)" - ) - self._provider = provider - - def __repr__(self) -> str: - return f"ResourcesAsTools({self._provider!r})" - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - """Add resource tools to the tool list.""" - return [ - *tools, - self._make_list_resources_tool(), - self._make_read_resource_tool(), - ] - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name, including generated resource tools.""" - if name == "list_resources": - return self._make_list_resources_tool() - if name == "read_resource": - return self._make_read_resource_tool() - return await call_next(name, version=version) - - def _make_list_resources_tool(self) -> Tool: - """Create the list_resources tool.""" - - async def list_resources() -> str: - """List all available resources and resource templates. - - Returns JSON with resource metadata. Static resources have a - 'uri' field, while templates have a 'uri_template' field with - placeholders like {name}. - """ - ctx = get_context() - resources = await ctx.fastmcp.list_resources() - templates = await ctx.fastmcp.list_resource_templates() - - result: list[dict[str, Any]] = [] - - for r in resources: - result.append( # noqa: PERF401 - { - "uri": str(r.uri), - "name": r.name, - "description": r.description, - "mime_type": r.mime_type, - } - ) - - for t in templates: - result.append( # noqa: PERF401 - { - "uri_template": t.uri_template, - "name": t.name, - "description": t.description, - } - ) - - return json.dumps(result, indent=2) - - return Tool.from_function(fn=list_resources, annotations=_DEFAULT_ANNOTATIONS) - - def _make_read_resource_tool(self) -> Tool: - """Create the read_resource tool.""" - - async def read_resource( - uri: Annotated[str, "The URI of the resource to read"], - ) -> str: - """Read a resource by its URI. - - For static resources, provide the exact URI. For templated - resources, provide the URI with template parameters filled in. - - Returns the resource content as a string. Binary content is - base64-encoded. - """ - ctx = get_context() - result = await ctx.fastmcp.read_resource(uri) - return _format_result(result) - - return Tool.from_function(fn=read_resource, annotations=_DEFAULT_ANNOTATIONS) - - -def _format_result(result: Any) -> str: - """Format ResourceResult for tool output. - - Single text content is returned as-is. Single binary content is - base64-encoded. Multiple contents are JSON-encoded. - """ - if len(result.contents) == 1: - content = result.contents[0].content - if isinstance(content, bytes): - return base64.b64encode(content).decode() - return content - - return json.dumps( - [ - { - "content": ( - c.content - if isinstance(c.content, str) - else base64.b64encode(c.content).decode() - ), - "mime_type": c.mime_type, - } - for c in result.contents - ] - ) +__all__ = ["ResourcesAsTools", "ResourcesAsToolsTransform"] diff --git a/tests/server/transforms/test_prompts_as_tools.py b/tests/server/plugins/test_prompts_as_tools.py similarity index 100% rename from tests/server/transforms/test_prompts_as_tools.py rename to tests/server/plugins/test_prompts_as_tools.py diff --git a/tests/server/plugins/test_prompts_as_tools_plugin.py b/tests/server/plugins/test_prompts_as_tools_plugin.py new file mode 100644 index 000000000..a67d3ea79 --- /dev/null +++ b/tests/server/plugins/test_prompts_as_tools_plugin.py @@ -0,0 +1,125 @@ +"""Tests for the PromptsAsTools plugin wrapper. + +Transform behavior is covered by `test_prompts_as_tools.py`. This file +only covers plugin-layer concerns — config validation, meta derivation, +and the deprecation shim at the old import path. +""" + +from __future__ import annotations + +import warnings + +import pytest +from pydantic import ValidationError + +from fastmcp import Client, FastMCP +from fastmcp.server.plugins.prompts_as_tools import ( + PromptsAsTools, + PromptsAsToolsConfig, +) + + +class TestPromptsAsToolsConfig: + def test_config_generic_binding(self): + assert PromptsAsTools._config_cls is PromptsAsToolsConfig + + def test_unknown_config_key_rejected(self): + with pytest.raises((ValidationError, Exception), match="forbid|extra"): + PromptsAsToolsConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + + def test_default_meta(self): + assert PromptsAsTools.meta.name == "prompts-as-tools" + assert PromptsAsTools.meta.version is None + + +class TestPromptsAsToolsPluginRegistration: + async def test_plugin_registers_synthetic_tools(self): + mcp = FastMCP("t", plugins=[PromptsAsTools()]) + + @mcp.prompt + def greet(name: str) -> str: + """Say hello.""" + return f"Hello {name}" + + async with Client(mcp) as c: + tools = await c.list_tools() + names = {t.name for t in tools} + + assert {"list_prompts", "get_prompt"}.issubset(names) + + +class TestDeprecationShim: + def test_old_path_emits_deprecation_warning(self): + import importlib + import sys + + from fastmcp.exceptions import FastMCPDeprecationWarning + + sys.modules.pop("fastmcp.server.transforms.prompts_as_tools", None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.import_module("fastmcp.server.transforms.prompts_as_tools") + + fastmcp_deprecations = [ + w for w in caught if issubclass(w.category, FastMCPDeprecationWarning) + ] + assert any( + "plugins.prompts_as_tools" in str(w.message) for w in fastmcp_deprecations + ), ( + f"expected FastMCPDeprecationWarning pointing at plugins.prompts_as_tools, " + f"got {[(w.category.__name__, str(w.message)) for w in caught]}" + ) + + async def test_legacy_add_transform_pattern_still_works(self): + """End-to-end: old `add_transform(PromptsAsTools(mcp))` code keeps + working. `PromptsAsTools` at the old path must remain the transform + class, not the plugin.""" + from fastmcp.exceptions import FastMCPDeprecationWarning + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FastMCPDeprecationWarning) + from fastmcp.server.transforms.prompts_as_tools import ( + PromptsAsTools as OldPromptsAsTools, + ) + + mcp = FastMCP("legacy") + + @mcp.prompt + def greet(name: str) -> str: + return f"Hello {name}" + + mcp.add_transform(OldPromptsAsTools(mcp)) + + tools = await mcp.list_tools(run_middleware=False) + assert {"list_prompts", "get_prompt"}.issubset({t.name for t in tools}) + + def test_top_level_import_does_not_emit_deprecation(self): + """`from fastmcp.server.transforms import Transform` should not + trigger a PromptsAsTools deprecation warning. The warning only + fires when the leaf module is imported directly.""" + import importlib + import sys + + from fastmcp.exceptions import FastMCPDeprecationWarning + + # Flush anything that might carry a cached import. + sys.modules.pop("fastmcp.server.transforms", None) + sys.modules.pop("fastmcp.server.transforms.prompts_as_tools", None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.import_module("fastmcp.server.transforms") + # Access the attr via __getattr__, which should NOT load the + # shim leaf module. + mod = sys.modules["fastmcp.server.transforms"] + _ = mod.Transform + + assert not any( + issubclass(w.category, FastMCPDeprecationWarning) + and "prompts_as_tools" in str(w.message) + for w in caught + ), ( + f"unexpected prompts_as_tools deprecation warning from top-level " + f"import: {[(w.category.__name__, str(w.message)) for w in caught]}" + ) diff --git a/tests/server/transforms/test_resources_as_tools.py b/tests/server/plugins/test_resources_as_tools.py similarity index 100% rename from tests/server/transforms/test_resources_as_tools.py rename to tests/server/plugins/test_resources_as_tools.py diff --git a/tests/server/plugins/test_resources_as_tools_plugin.py b/tests/server/plugins/test_resources_as_tools_plugin.py new file mode 100644 index 000000000..156ea0a81 --- /dev/null +++ b/tests/server/plugins/test_resources_as_tools_plugin.py @@ -0,0 +1,91 @@ +"""Tests for the ResourcesAsTools plugin wrapper. + +Transform behavior is covered by `test_resources_as_tools.py`. This file +only covers plugin-layer concerns — config validation, meta derivation, +and the deprecation shim at the old import path. +""" + +from __future__ import annotations + +import warnings + +import pytest +from pydantic import ValidationError + +from fastmcp import Client, FastMCP +from fastmcp.server.plugins.resources_as_tools import ( + ResourcesAsTools, + ResourcesAsToolsConfig, +) + + +class TestResourcesAsToolsConfig: + def test_config_generic_binding(self): + assert ResourcesAsTools._config_cls is ResourcesAsToolsConfig + + def test_unknown_config_key_rejected(self): + with pytest.raises((ValidationError, Exception), match="forbid|extra"): + ResourcesAsToolsConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + + def test_default_meta(self): + assert ResourcesAsTools.meta.name == "resources-as-tools" + assert ResourcesAsTools.meta.version is None + + +class TestResourcesAsToolsPluginRegistration: + async def test_plugin_registers_synthetic_tools(self): + mcp = FastMCP("t", plugins=[ResourcesAsTools()]) + + @mcp.resource("test://hello") + def hello() -> str: + return "world" + + async with Client(mcp) as c: + tools = await c.list_tools() + names = {t.name for t in tools} + + assert {"list_resources", "read_resource"}.issubset(names) + + +class TestDeprecationShim: + def test_old_path_emits_deprecation_warning(self): + import importlib + import sys + + from fastmcp.exceptions import FastMCPDeprecationWarning + + sys.modules.pop("fastmcp.server.transforms.resources_as_tools", None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.import_module("fastmcp.server.transforms.resources_as_tools") + + fastmcp_deprecations = [ + w for w in caught if issubclass(w.category, FastMCPDeprecationWarning) + ] + assert any( + "plugins.resources_as_tools" in str(w.message) for w in fastmcp_deprecations + ), ( + f"expected FastMCPDeprecationWarning pointing at plugins.resources_as_tools, " + f"got {[(w.category.__name__, str(w.message)) for w in caught]}" + ) + + async def test_legacy_add_transform_pattern_still_works(self): + from fastmcp.exceptions import FastMCPDeprecationWarning + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FastMCPDeprecationWarning) + from fastmcp.server.transforms.resources_as_tools import ( + ResourcesAsTools as OldResourcesAsTools, + ) + + mcp = FastMCP("legacy") + + @mcp.resource("test://hello") + def hello() -> str: + return "world" + + mcp.add_transform(OldResourcesAsTools(mcp)) + + tools = await mcp.list_tools(run_middleware=False) + assert {"list_resources", "read_resource"}.issubset({t.name for t in tools}) From a82979e4338d44e802639c207dafaf767562fb54 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:56:56 -0400 Subject: [PATCH 12/17] Convert OpenAPI provider to the OpenAPI plugin (#4015) --- .../experimental/server/openapi/__init__.py | 16 +- src/fastmcp/server/openapi/__init__.py | 20 +- src/fastmcp/server/openapi/components.py | 6 +- src/fastmcp/server/openapi/routing.py | 14 +- src/fastmcp/server/openapi/server.py | 13 +- .../server/plugins/openapi/__init__.py | 21 + .../server/plugins/openapi/components.py | 421 ++++++++++++++++ src/fastmcp/server/plugins/openapi/plugin.py | 247 ++++++++++ .../server/plugins/openapi/provider.py | 459 ++++++++++++++++++ src/fastmcp/server/plugins/openapi/routing.py | 109 +++++ .../server/providers/openapi/__init__.py | 32 +- .../server/providers/openapi/components.py | 424 +--------------- .../server/providers/openapi/provider.py | 447 +---------------- .../server/providers/openapi/routing.py | 118 +---- src/fastmcp/server/server.py | 45 +- tests/deprecated/openapi/test_openapi.py | 8 +- .../openapi/__init__.py | 0 .../openapi/test_comprehensive.py | 4 +- .../openapi/test_deepobject_style.py | 2 +- .../openapi/test_end_to_end_compatibility.py | 2 +- .../openapi/test_openapi_features.py | 6 +- .../openapi/test_openapi_performance.py | 0 .../openapi/test_parameter_collisions.py | 2 +- .../openapi/test_performance_comparison.py | 2 +- .../openapi/test_server.py | 3 +- tests/server/plugins/test_openapi_plugin.py | 286 +++++++++++ 26 files changed, 1693 insertions(+), 1014 deletions(-) create mode 100644 src/fastmcp/server/plugins/openapi/__init__.py create mode 100644 src/fastmcp/server/plugins/openapi/components.py create mode 100644 src/fastmcp/server/plugins/openapi/plugin.py create mode 100644 src/fastmcp/server/plugins/openapi/provider.py create mode 100644 src/fastmcp/server/plugins/openapi/routing.py rename tests/server/{providers => plugins}/openapi/__init__.py (100%) rename tests/server/{providers => plugins}/openapi/test_comprehensive.py (99%) rename tests/server/{providers => plugins}/openapi/test_deepobject_style.py (99%) rename tests/server/{providers => plugins}/openapi/test_end_to_end_compatibility.py (99%) rename tests/server/{providers => plugins}/openapi/test_openapi_features.py (99%) rename tests/server/{providers => plugins}/openapi/test_openapi_performance.py (100%) rename tests/server/{providers => plugins}/openapi/test_parameter_collisions.py (99%) rename tests/server/{providers => plugins}/openapi/test_performance_comparison.py (99%) rename tests/server/{providers => plugins}/openapi/test_server.py (99%) create mode 100644 tests/server/plugins/test_openapi_plugin.py diff --git a/src/fastmcp/experimental/server/openapi/__init__.py b/src/fastmcp/experimental/server/openapi/__init__.py index b19563400..064b5ef9d 100644 --- a/src/fastmcp/experimental/server/openapi/__init__.py +++ b/src/fastmcp/experimental/server/openapi/__init__.py @@ -1,4 +1,4 @@ -"""Deprecated: Import from fastmcp.server.providers.openapi instead.""" +"""Deprecated: Import from fastmcp.server.plugins.openapi instead.""" import warnings @@ -7,23 +7,27 @@ from fastmcp.exceptions import FastMCPDeprecationWarning # Deprecated in 2.14 when OpenAPI support was promoted out of experimental warnings.warn( "Importing from fastmcp.experimental.server.openapi is deprecated. " - "Import from fastmcp.server.providers.openapi instead.", + "Import from fastmcp.server.plugins.openapi instead.", FastMCPDeprecationWarning, stacklevel=2, ) # Import from canonical location from fastmcp.server.openapi.server import FastMCPOpenAPI as FastMCPOpenAPI # noqa: E402 -from fastmcp.server.providers.openapi import ( # noqa: E402 - ComponentFn as ComponentFn, +from fastmcp.server.plugins.openapi import ( # noqa: E402 MCPType as MCPType, + RouteMap as RouteMap, +) +from fastmcp.server.plugins.openapi.components import ( # noqa: E402 OpenAPIResource as OpenAPIResource, OpenAPIResourceTemplate as OpenAPIResourceTemplate, OpenAPITool as OpenAPITool, - RouteMap as RouteMap, +) +from fastmcp.server.plugins.openapi.routing import ( # noqa: E402 + ComponentFn as ComponentFn, RouteMapFn as RouteMapFn, ) -from fastmcp.server.providers.openapi.routing import ( # noqa: E402 +from fastmcp.server.plugins.openapi.routing import ( # noqa: E402 DEFAULT_ROUTE_MAPPINGS as DEFAULT_ROUTE_MAPPINGS, _determine_route_type as _determine_route_type, ) diff --git a/src/fastmcp/server/openapi/__init__.py b/src/fastmcp/server/openapi/__init__.py index 3ea81d109..e5258e73c 100644 --- a/src/fastmcp/server/openapi/__init__.py +++ b/src/fastmcp/server/openapi/__init__.py @@ -1,12 +1,12 @@ """OpenAPI server implementation for FastMCP. .. deprecated:: - This module is deprecated. Import from fastmcp.server.providers.openapi instead. + This module is deprecated. Import from fastmcp.server.plugins.openapi instead. The recommended approach is to use OpenAPIProvider with FastMCP: from fastmcp import FastMCP - from fastmcp.server.providers.openapi import OpenAPIProvider + from fastmcp.server.plugins.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") @@ -24,20 +24,26 @@ from fastmcp.exceptions import FastMCPDeprecationWarning warnings.warn( "fastmcp.server.openapi is deprecated. " - "Import from fastmcp.server.providers.openapi instead.", + "Import from fastmcp.server.plugins.openapi instead.", FastMCPDeprecationWarning, stacklevel=2, ) # Re-export from new canonical location -from fastmcp.server.providers.openapi import ( # noqa: E402 - ComponentFn as ComponentFn, +from fastmcp.server.plugins.openapi import ( # noqa: E402 MCPType as MCPType, - OpenAPIProvider as OpenAPIProvider, + RouteMap as RouteMap, +) +from fastmcp.server.plugins.openapi.components import ( # noqa: E402 OpenAPIResource as OpenAPIResource, OpenAPIResourceTemplate as OpenAPIResourceTemplate, OpenAPITool as OpenAPITool, - RouteMap as RouteMap, +) +from fastmcp.server.plugins.openapi.provider import ( # noqa: E402 + OpenAPIProvider as OpenAPIProvider, +) +from fastmcp.server.plugins.openapi.routing import ( # noqa: E402 + ComponentFn as ComponentFn, RouteMapFn as RouteMapFn, ) diff --git a/src/fastmcp/server/openapi/components.py b/src/fastmcp/server/openapi/components.py index ce1eeaf7d..3861d1a3f 100644 --- a/src/fastmcp/server/openapi/components.py +++ b/src/fastmcp/server/openapi/components.py @@ -1,6 +1,6 @@ """OpenAPI component implementations - backwards compatibility stub. -This module is deprecated. Import from fastmcp.server.providers.openapi instead. +This module is deprecated. Import from fastmcp.server.plugins.openapi instead. """ from __future__ import annotations @@ -11,12 +11,12 @@ from fastmcp.exceptions import FastMCPDeprecationWarning warnings.warn( "fastmcp.server.openapi.components is deprecated. " - "Import from fastmcp.server.providers.openapi instead.", + "Import from fastmcp.server.plugins.openapi instead.", FastMCPDeprecationWarning, stacklevel=2, ) -from fastmcp.server.providers.openapi import ( # noqa: E402 +from fastmcp.server.plugins.openapi.components import ( # noqa: E402 OpenAPIResource, OpenAPIResourceTemplate, OpenAPITool, diff --git a/src/fastmcp/server/openapi/routing.py b/src/fastmcp/server/openapi/routing.py index 309e503ca..f2e760c0e 100644 --- a/src/fastmcp/server/openapi/routing.py +++ b/src/fastmcp/server/openapi/routing.py @@ -22,27 +22,27 @@ __all__ = [ warnings.warn( "fastmcp.server.openapi.routing is deprecated. " - "Import from fastmcp.server.providers.openapi instead.", + "Import from fastmcp.server.plugins.openapi instead.", FastMCPDeprecationWarning, stacklevel=2, ) # Re-export from new canonical location -from fastmcp.server.providers.openapi.routing import ( +from fastmcp.server.plugins.openapi.routing import ( DEFAULT_ROUTE_MAPPINGS as DEFAULT_ROUTE_MAPPINGS, ) -from fastmcp.server.providers.openapi.routing import ( +from fastmcp.server.plugins.openapi.routing import ( ComponentFn as ComponentFn, ) -from fastmcp.server.providers.openapi.routing import ( +from fastmcp.server.plugins.openapi.routing import ( MCPType as MCPType, ) -from fastmcp.server.providers.openapi.routing import ( +from fastmcp.server.plugins.openapi.routing import ( RouteMap as RouteMap, ) -from fastmcp.server.providers.openapi.routing import ( +from fastmcp.server.plugins.openapi.routing import ( RouteMapFn as RouteMapFn, ) -from fastmcp.server.providers.openapi.routing import ( +from fastmcp.server.plugins.openapi.routing import ( _determine_route_type as _determine_route_type, ) diff --git a/src/fastmcp/server/openapi/server.py b/src/fastmcp/server/openapi/server.py index a7292129d..f790d4127 100644 --- a/src/fastmcp/server/openapi/server.py +++ b/src/fastmcp/server/openapi/server.py @@ -3,7 +3,7 @@ This class is deprecated. Use FastMCP with OpenAPIProvider instead: from fastmcp import FastMCP - from fastmcp.server.providers.openapi import OpenAPIProvider + from fastmcp.server.plugins.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") @@ -19,12 +19,9 @@ from typing import Any import httpx from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.providers.openapi import ( - ComponentFn, - OpenAPIProvider, - RouteMap, - RouteMapFn, -) +from fastmcp.server.plugins.openapi import RouteMap +from fastmcp.server.plugins.openapi.provider import OpenAPIProvider +from fastmcp.server.plugins.openapi.routing import ComponentFn, RouteMapFn from fastmcp.server.server import FastMCP @@ -49,7 +46,7 @@ class FastMCPOpenAPI(FastMCP): New approach: ```python from fastmcp import FastMCP - from fastmcp.server.providers.openapi import OpenAPIProvider + from fastmcp.server.plugins.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") diff --git a/src/fastmcp/server/plugins/openapi/__init__.py b/src/fastmcp/server/plugins/openapi/__init__.py new file mode 100644 index 000000000..d9ef7e1ca --- /dev/null +++ b/src/fastmcp/server/plugins/openapi/__init__.py @@ -0,0 +1,21 @@ +"""OpenAPI plugin — mount an OpenAPI spec as MCP tools/resources. + + from fastmcp import FastMCP + from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig + + mcp = FastMCP( + "Petstore", + plugins=[OpenAPI(OpenAPIConfig(spec=petstore_spec))], + ) + +Typed `RouteMap` + `MCPType` are re-exported for the Python-only +escape hatch on `OpenAPI.__init__(route_maps=...)`. Everything else +(component classes, provider class, callable type aliases) lives on the +submodules — import from `.provider`, `.components`, `.routing` directly +if you need them. +""" + +from fastmcp.server.plugins.openapi.plugin import OpenAPI, OpenAPIConfig +from fastmcp.server.plugins.openapi.routing import MCPType, RouteMap + +__all__ = ["MCPType", "OpenAPI", "OpenAPIConfig", "RouteMap"] diff --git a/src/fastmcp/server/plugins/openapi/components.py b/src/fastmcp/server/plugins/openapi/components.py new file mode 100644 index 000000000..5d8cee1f4 --- /dev/null +++ b/src/fastmcp/server/plugins/openapi/components.py @@ -0,0 +1,421 @@ +"""OpenAPI component classes: Tool, Resource, and ResourceTemplate.""" + +from __future__ import annotations + +import json +import re +import warnings +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +import httpx +from mcp.types import ToolAnnotations +from pydantic.networks import AnyUrl + +import fastmcp +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.resources import ( + Resource, + ResourceContent, + ResourceResult, + ResourceTemplate, +) +from fastmcp.server.dependencies import get_http_headers +from fastmcp.server.tasks.config import TaskConfig +from fastmcp.tools.base import Tool, ToolResult +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.openapi import HTTPRoute +from fastmcp.utilities.openapi.director import RequestDirector + +if TYPE_CHECKING: + from fastmcp.server import Context + +_SAFE_HEADERS = frozenset( + { + "accept", + "accept-encoding", + "accept-language", + "cache-control", + "connection", + "content-length", + "content-type", + "host", + "user-agent", + } +) + + +def _redact_headers(headers: httpx.Headers) -> dict[str, str]: + return {k: v if k.lower() in _SAFE_HEADERS else "***" for k, v in headers.items()} + + +__all__ = [ + "OpenAPIResource", + "OpenAPIResourceTemplate", + "OpenAPITool", + "_extract_mime_type_from_route", +] + +logger = get_logger(__name__) + +# Default MIME type when no response content type can be inferred +_DEFAULT_MIME_TYPE = "application/json" + + +def _extract_mime_type_from_route(route: HTTPRoute) -> str: + """Extract the primary MIME type from an HTTPRoute's response definitions. + + Looks for the first successful response (2xx) and returns its content type. + Prefers JSON-compatible types when multiple are available. + Falls back to "application/json" when no response content type is declared. + """ + if not route.responses: + return _DEFAULT_MIME_TYPE + + # Priority order for success status codes + success_codes = ["200", "201", "202", "204"] + + response_info = None + for status_code in success_codes: + if status_code in route.responses: + response_info = route.responses[status_code] + break + + # If no explicit success codes, try any 2xx response + if response_info is None: + for status_code, resp_info in route.responses.items(): + if status_code.startswith("2"): + response_info = resp_info + break + + if response_info is None or not response_info.content_schema: + return _DEFAULT_MIME_TYPE + + # If there's only one content type, use it directly + content_types = list(response_info.content_schema.keys()) + if len(content_types) == 1: + return content_types[0] + + # When multiple types exist, prefer JSON-compatible types + json_compatible_types = [ + "application/json", + "application/vnd.api+json", + "application/hal+json", + "application/ld+json", + "text/json", + ] + for ct in json_compatible_types: + if ct in response_info.content_schema: + return ct + + # Fall back to the first available content type + return content_types[0] + + +def _slugify(text: str) -> str: + """Convert text to a URL-friendly slug format. + + Only contains lowercase letters, uppercase letters, numbers, and underscores. + """ + if not text: + return "" + + # Replace spaces and common separators with underscores + slug = re.sub(r"[\s\-\.]+", "_", text) + + # Remove non-alphanumeric characters except underscores + slug = re.sub(r"[^a-zA-Z0-9_]", "", slug) + + # Remove multiple consecutive underscores + slug = re.sub(r"_+", "_", slug) + + # Remove leading/trailing underscores + slug = slug.strip("_") + + return slug + + +class OpenAPITool(Tool): + """Tool implementation for OpenAPI endpoints.""" + + task_config: TaskConfig = TaskConfig(mode="forbidden") + + def __init__( + self, + client: httpx.AsyncClient, + route: HTTPRoute, + director: RequestDirector, + name: str, + description: str, + parameters: dict[str, Any], + output_schema: dict[str, Any] | None = None, + tags: set[str] | None = None, + annotations: ToolAnnotations | None = None, + serializer: Callable[[Any], str] | None = None, # Deprecated + ): + if serializer is not None and fastmcp.settings.deprecation_warnings: + warnings.warn( + "The `serializer` parameter is deprecated. " + "Return ToolResult from your tools for full control over serialization. " + "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", + FastMCPDeprecationWarning, + stacklevel=2, + ) + super().__init__( + name=name, + description=description, + parameters=parameters, + output_schema=output_schema, + tags=tags or set(), + annotations=annotations, + serializer=serializer, + ) + self._client = client + self._route = route + self._director = director + + def __repr__(self) -> str: + return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})" + + async def run(self, arguments: dict[str, Any]) -> ToolResult: + """Execute the HTTP request using RequestDirector.""" + # Build the request — errors here are programming/schema issues, + # not HTTP failures, so we catch them separately. + try: + base_url = str(self._client.base_url) or "http://localhost" + request = self._director.build(self._route, arguments, base_url) + + if self._client.headers: + for key, value in self._client.headers.items(): + if key not in request.headers: + request.headers[key] = value + + mcp_headers = get_http_headers() + if mcp_headers: + for key, value in mcp_headers.items(): + if key not in request.headers: + request.headers[key] = value + except Exception as e: + raise ValueError( + f"Error building request for {self._route.method.upper()} " + f"{self._route.path}: {type(e).__name__}: {e}" + ) from e + + # Send the request and process the response. + try: + logger.debug( + f"run - sending request; headers: {_redact_headers(request.headers)}" + ) + + response = await self._client.send(request) + response.raise_for_status() + + # Try to parse as JSON first + try: + result = response.json() + + # Handle structured content based on output schema + if self.output_schema is not None: + if self.output_schema.get("x-fastmcp-wrap-result"): + structured_output = {"result": result} + else: + structured_output = result + elif not isinstance(result, dict): + structured_output = {"result": result} + else: + structured_output = result + + # Structured content must be a dict for the MCP protocol. + # Wrap non-dict values that slipped through (e.g. a backend + # returning an array when the schema declared an object). + if not isinstance(structured_output, dict): + structured_output = {"result": structured_output} + + return ToolResult(structured_content=structured_output) + except json.JSONDecodeError: + return ToolResult(content=response.text) + + except httpx.HTTPStatusError as e: + error_message = ( + f"HTTP error {e.response.status_code}: {e.response.reason_phrase}" + ) + try: + error_data = e.response.json() + error_message += f" - {error_data}" + except (json.JSONDecodeError, ValueError): + if e.response.text: + error_message += f" - {e.response.text}" + raise ValueError(error_message) from e + + except httpx.TimeoutException as e: + raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e + + except httpx.RequestError as e: + raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e + + +class OpenAPIResource(Resource): + """Resource implementation for OpenAPI endpoints.""" + + task_config: TaskConfig = TaskConfig(mode="forbidden") + + def __init__( + self, + client: httpx.AsyncClient, + route: HTTPRoute, + director: RequestDirector, + uri: str, + name: str, + description: str, + mime_type: str = "application/json", + tags: set[str] | None = None, + ): + super().__init__( + uri=AnyUrl(uri), + name=name, + description=description, + mime_type=mime_type, + tags=tags or set(), + ) + self._client = client + self._route = route + self._director = director + + def __repr__(self) -> str: + return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})" + + async def read(self) -> ResourceResult: + """Fetch the resource data by making an HTTP request.""" + try: + path = self._route.path + resource_uri = str(self.uri) + + # If this is a templated resource, extract path parameters from the URI + if "{" in path and "}" in path: + parts = resource_uri.split("/") + + if len(parts) > 1: + path_params = {} + param_matches = re.findall(r"\{([^}]+)\}", path) + if param_matches: + param_matches.sort(reverse=True) + expected_param_count = len(parts) - 1 + for i, param_name in enumerate(param_matches): + if i < expected_param_count: + param_value = parts[-1 - i] + path_params[param_name] = param_value + + for param_name, param_value in path_params.items(): + path = path.replace(f"{{{param_name}}}", str(param_value)) + + # Build headers with correct precedence + headers: dict[str, str] = {} + if self._client.headers: + headers.update(self._client.headers) + mcp_headers = get_http_headers() + if mcp_headers: + headers.update(mcp_headers) + + response = await self._client.request( + method=self._route.method, + url=path, + headers=headers, + ) + response.raise_for_status() + + content_type = response.headers.get("content-type", "").lower() + + if "application/json" in content_type: + result = response.json() + return ResourceResult( + contents=[ + ResourceContent( + content=json.dumps(result), mime_type="application/json" + ) + ] + ) + elif any(ct in content_type for ct in ["text/", "application/xml"]): + return ResourceResult( + contents=[ + ResourceContent(content=response.text, mime_type=self.mime_type) + ] + ) + else: + return ResourceResult( + contents=[ + ResourceContent( + content=response.content, mime_type=self.mime_type + ) + ] + ) + + except httpx.HTTPStatusError as e: + error_message = ( + f"HTTP error {e.response.status_code}: {e.response.reason_phrase}" + ) + try: + error_data = e.response.json() + error_message += f" - {error_data}" + except (json.JSONDecodeError, ValueError): + if e.response.text: + error_message += f" - {e.response.text}" + raise ValueError(error_message) from e + + except httpx.TimeoutException as e: + raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e + + except httpx.RequestError as e: + raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e + + +class OpenAPIResourceTemplate(ResourceTemplate): + """Resource template implementation for OpenAPI endpoints.""" + + task_config: TaskConfig = TaskConfig(mode="forbidden") + + def __init__( + self, + client: httpx.AsyncClient, + route: HTTPRoute, + director: RequestDirector, + uri_template: str, + name: str, + description: str, + parameters: dict[str, Any], + tags: set[str] | None = None, + mime_type: str = _DEFAULT_MIME_TYPE, + ): + super().__init__( + uri_template=uri_template, + name=name, + description=description, + parameters=parameters, + tags=tags or set(), + mime_type=mime_type, + ) + self._client = client + self._route = route + self._director = director + + def __repr__(self) -> str: + return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})" + + async def create_resource( + self, + uri: str, + params: dict[str, Any], + context: Context | None = None, + ) -> Resource: + """Create a resource with the given parameters.""" + uri_parts = [f"{key}={value}" for key, value in params.items()] + + return OpenAPIResource( + client=self._client, + route=self._route, + director=self._director, + uri=uri, + name=f"{self.name}-{'-'.join(uri_parts)}", + description=self.description or f"Resource for {self._route.path}", + mime_type=self.mime_type, + tags=set(self._route.tags or []), + ) diff --git a/src/fastmcp/server/plugins/openapi/plugin.py b/src/fastmcp/server/plugins/openapi/plugin.py new file mode 100644 index 000000000..f1ca2227a --- /dev/null +++ b/src/fastmcp/server/plugins/openapi/plugin.py @@ -0,0 +1,247 @@ +"""OpenAPI plugin: wrap an OpenAPI spec into an MCP server via the +`OpenAPIProvider`. + +The plugin is the JSON-configurable entry point for the OpenAPI +integration. Spec, base URL, headers, timeout, and route mappings can +all be declared in a plugin config (useful for `plugins.json`, Horizon +config forms, or anywhere else you want to spin up an OpenAPI server +without writing Python). For scenarios that need a custom +`httpx.AsyncClient` or callables (`route_map_fn`, `mcp_component_fn`), +pass them through `__init__` directly. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Literal + +import httpx +from pydantic import BaseModel, ConfigDict + +from fastmcp.server.plugins.base import Plugin, PluginMeta +from fastmcp.server.plugins.openapi.provider import ( + OpenAPIProvider, + resolve_spec_base_url, +) +from fastmcp.server.plugins.openapi.routing import ( + ComponentFn, + MCPType, + RouteMap, + RouteMapFn, +) +from fastmcp.server.providers import Provider +from fastmcp.utilities.openapi.models import HttpMethod + + +class RouteMapDict(BaseModel): + """JSON-serializable form of `RouteMap`. + + Converted to a real `RouteMap` when the plugin builds the provider. + The `pattern` field is always a regex string (the typed `RouteMap` + accepts a compiled `Pattern` too, but Config stays JSON-friendly). + """ + + model_config = ConfigDict(extra="forbid") + + mcp_type: Literal["TOOL", "RESOURCE", "RESOURCE_TEMPLATE", "EXCLUDE"] + """Target component type. Matches `MCPType` enum values.""" + + methods: list[HttpMethod] | Literal["*"] = "*" + """HTTP methods to match (e.g. `["GET", "POST"]`) or `"*"` for any.""" + + pattern: str = r".*" + """Regex pattern matched against the route path.""" + + tags: list[str] = [] + """Route tags that must all be present for this mapping to apply.""" + + mcp_tags: list[str] = [] + """Tags to attach to the generated MCP component.""" + + def to_route_map(self) -> RouteMap: + methods: list[HttpMethod] | Literal["*"] = ( + "*" if self.methods == "*" else list(self.methods) + ) + return RouteMap( + methods=methods, + pattern=self.pattern, + tags=set(self.tags), + mcp_type=MCPType[self.mcp_type], + mcp_tags=set(self.mcp_tags), + ) + + +class OpenAPIConfig(BaseModel): + """Config model for the `OpenAPI` plugin. + + Exactly one of `spec` or `spec_path` must be set — the check fires + when the plugin builds its provider, not at Config construction, + so that `OpenAPIConfig()` with no args still satisfies the + plugin-framework's defaults-are-instantiable contract. + + For specs that need to be fetched from a URL at startup, fetch the + dict in your application code and pass it via `spec=...`. + """ + + model_config = ConfigDict(extra="forbid") + + spec: dict[str, Any] | None = None + """Inline OpenAPI spec as a dict.""" + + spec_path: str | None = None + """Path to a local JSON file containing the OpenAPI spec.""" + + base_url: str | None = None + """Base URL for the default httpx client. If omitted, the first + server URL from the spec is used.""" + + headers: dict[str, str] | None = None + """Default headers added to every request the generated client + makes.""" + + timeout_secs: float = 30.0 + """Default timeout (seconds) for the generated httpx client.""" + + mcp_names: dict[str, str] | None = None + """Mapping from OpenAPI `operationId` to the MCP component name + that gets generated for it.""" + + tags: list[str] = [] + """Tags applied to every generated MCP component.""" + + validate_output: bool = True + """When true (default), generated tools use the OpenAPI response + schema for output validation. Set false to accept any shape.""" + + route_maps: list[RouteMapDict] = [] + """Ordered route-mapping rules. First match wins. If omitted, all + routes become tools.""" + + +class OpenAPI(Plugin[OpenAPIConfig]): + """Mount an OpenAPI spec as an MCP server via a plugin. + + Everything declarative (spec, base URL, headers, route mappings) + goes in `OpenAPIConfig`. Python-only knobs — custom `httpx.AsyncClient`, + route-mapping callables, component customization — go in `__init__` + kwargs. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig + + # Declarative (JSON-friendly): + mcp = FastMCP( + "Petstore", + plugins=[ + OpenAPI( + OpenAPIConfig( + spec=petstore_spec, + base_url="https://api.example.com", + headers={"Authorization": "Bearer ..."}, + ) + ) + ], + ) + + # With a custom httpx client (shared auth, retries, etc.): + custom_client = httpx.AsyncClient(...) + mcp = FastMCP( + "Petstore", + plugins=[ + OpenAPI( + OpenAPIConfig(spec=petstore_spec), + client=custom_client, + ) + ], + ) + ``` + """ + + # "OpenAPI" is a single technical term; the auto-kebab would split + # it into "open-api", which is uglier than the established spelling. + meta = PluginMeta(name="openapi") + + def __init__( + self, + config: OpenAPIConfig | dict[str, Any] | None = None, + *, + client: httpx.AsyncClient | None = None, + route_maps: list[RouteMap] | None = None, + route_map_fn: RouteMapFn | None = None, + mcp_component_fn: ComponentFn | None = None, + ) -> None: + super().__init__(config) + self._client_override = client + self._route_maps_override = route_maps + self._route_map_fn = route_map_fn + self._mcp_component_fn = mcp_component_fn + + def providers(self) -> list[Provider]: + spec = self._load_spec() + if self._client_override is not None: + client = self._client_override + # User-supplied client: they own the lifecycle. + owns_client: bool | None = None + else: + client = self._build_default_client(spec) + # Plugin built the client, so the provider lifespan must + # close it on shutdown (default ownership heuristic would + # miss this since `client` is not None by the time we pass + # it in). + owns_client = True + route_maps = self._resolve_route_maps() + + return [ + OpenAPIProvider( + openapi_spec=spec, + client=client, + route_maps=route_maps, + route_map_fn=self._route_map_fn, + mcp_component_fn=self._mcp_component_fn, + mcp_names=self.config.mcp_names, + tags=set(self.config.tags) if self.config.tags else None, + validate_output=self.config.validate_output, + _owns_client=owns_client, + ) + ] + + def _load_spec(self) -> dict[str, Any]: + if self.config.spec is not None and self.config.spec_path is not None: + raise ValueError( + "OpenAPIConfig requires exactly one of `spec` or `spec_path`, not both." + ) + if self.config.spec is not None: + return self.config.spec + if self.config.spec_path is not None: + # Force UTF-8 rather than relying on the process locale — + # OpenAPI specs can carry non-ASCII descriptions and we want + # cross-platform (e.g. Windows cp1252) loads to work. + return json.loads(Path(self.config.spec_path).read_text(encoding="utf-8")) + raise ValueError( + "OpenAPIConfig requires `spec` (inline dict) or `spec_path` " + "(local JSON file) to be set." + ) + + def _build_default_client(self, spec: dict[str, Any]) -> httpx.AsyncClient: + kwargs: dict[str, Any] = { + "base_url": self.config.base_url or resolve_spec_base_url(spec), + "timeout": self.config.timeout_secs, + } + if self.config.headers: + kwargs["headers"] = self.config.headers + return httpx.AsyncClient(**kwargs) + + def _resolve_route_maps(self) -> list[RouteMap] | None: + # Typed override wins over dict-form config so power users who + # pass real RouteMap objects aren't shadowed by an empty default. + if self._route_maps_override is not None: + return self._route_maps_override + if self.config.route_maps: + return [rm.to_route_map() for rm in self.config.route_maps] + return None + + +__all__ = ["OpenAPI", "OpenAPIConfig", "RouteMapDict"] diff --git a/src/fastmcp/server/plugins/openapi/provider.py b/src/fastmcp/server/plugins/openapi/provider.py new file mode 100644 index 000000000..306b06faf --- /dev/null +++ b/src/fastmcp/server/plugins/openapi/provider.py @@ -0,0 +1,459 @@ +"""OpenAPIProvider for creating MCP components from OpenAPI specifications.""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import AsyncIterator, Sequence +from contextlib import asynccontextmanager +from typing import Any, Literal, cast + +import httpx +from jsonschema_path import SchemaPath + +from fastmcp.prompts import Prompt +from fastmcp.resources import Resource, ResourceTemplate +from fastmcp.server.plugins.openapi.components import ( + OpenAPIResource, + OpenAPIResourceTemplate, + OpenAPITool, + _extract_mime_type_from_route, + _slugify, +) +from fastmcp.server.plugins.openapi.routing import ( + DEFAULT_ROUTE_MAPPINGS, + ComponentFn, + MCPType, + RouteMap, + RouteMapFn, + _determine_route_type, +) +from fastmcp.server.providers.base import Provider +from fastmcp.tools.base import Tool +from fastmcp.utilities.components import FastMCPComponent +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.openapi import ( + HTTPRoute, + extract_output_schema_from_responses, + parse_openapi_to_http_routes, +) +from fastmcp.utilities.openapi.director import RequestDirector +from fastmcp.utilities.versions import VersionSpec, version_sort_key + +__all__ = [ + "OpenAPIProvider", +] + +logger = get_logger(__name__) + +DEFAULT_TIMEOUT: float = 30.0 + + +def resolve_spec_base_url(openapi_spec: dict[str, Any]) -> str: + """Resolve the first `servers[0].url` in an OpenAPI spec, substituting + any `servers[0].variables[name].default` values into `{name}` + placeholders. + + Raised to module level so callers that build their own httpx client + (e.g. the `OpenAPI` plugin applying user-configured headers/timeout) + can still honor spec server templates without duplicating the + substitution logic. + """ + servers = openapi_spec.get("servers", []) + if not servers or not servers[0].get("url"): + raise ValueError( + "No server URL found in OpenAPI spec. Either add a 'servers' " + "entry to the spec or provide an httpx.AsyncClient explicitly." + ) + base_url = servers[0]["url"] + variables = servers[0].get("variables", {}) + for name, var in variables.items(): + base_url = base_url.replace(f"{{{name}}}", var.get("default", "")) + return base_url + + +class OpenAPIProvider(Provider): + """Provider that creates MCP components from an OpenAPI specification. + + Components are created eagerly during initialization by parsing the OpenAPI + spec. Each component makes HTTP calls to the described API endpoints. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.openapi import OpenAPIProvider + import httpx + + client = httpx.AsyncClient(base_url="https://api.example.com") + provider = OpenAPIProvider(openapi_spec=spec, client=client) + + mcp = FastMCP("API Server") + mcp.add_provider(provider) + ``` + """ + + def __init__( + self, + openapi_spec: dict[str, Any], + client: httpx.AsyncClient | None = None, + *, + route_maps: list[RouteMap] | None = None, + route_map_fn: RouteMapFn | None = None, + mcp_component_fn: ComponentFn | None = None, + mcp_names: dict[str, str] | None = None, + tags: set[str] | None = None, + validate_output: bool = True, + _owns_client: bool | None = None, + ): + """Initialize provider by parsing OpenAPI spec and creating components. + + Args: + openapi_spec: OpenAPI schema as a dictionary + client: Optional httpx AsyncClient for making HTTP requests. + If not provided, a default client is created using the first + server URL from the OpenAPI spec with a 30-second timeout. + To customize timeout or other settings, pass your own client. + route_maps: Optional list of RouteMap objects defining route mappings + route_map_fn: Optional callable for advanced route type mapping + mcp_component_fn: Optional callable for component customization + mcp_names: Optional dictionary mapping operationId to component names + tags: Optional set of tags to add to all components + validate_output: If True (default), tools use the output schema + extracted from the OpenAPI spec for response validation. If + False, a permissive schema is used instead, allowing any + response structure while still returning structured JSON. + _owns_client: Private opt-in for callers (like the OpenAPI plugin) + that built `client` themselves and want the provider's lifespan + to close it on shutdown. Leave `None` for the default + "own it iff we built it here" behavior. + """ + super().__init__() + + if _owns_client is None: + _owns_client = client is None + self._owns_client = _owns_client + if client is None: + client = self._create_default_client(openapi_spec) + self._client = client + self._mcp_component_fn = mcp_component_fn + self._validate_output = validate_output + + # Keep track of names to detect collisions + self._used_names: dict[str, Counter[str]] = { + "tool": Counter(), + "resource": Counter(), + "resource_template": Counter(), + "prompt": Counter(), + } + + # Pre-created component storage + self._tools: dict[str, OpenAPITool] = {} + self._resources: dict[str, OpenAPIResource] = {} + self._templates: dict[str, OpenAPIResourceTemplate] = {} + + # Create openapi-core Spec and RequestDirector + try: + self._spec = SchemaPath.from_dict(cast(Any, openapi_spec)) + self._director = RequestDirector(self._spec) + except Exception as e: + logger.exception("Failed to initialize RequestDirector") + raise ValueError(f"Invalid OpenAPI specification: {e}") from e + + http_routes = parse_openapi_to_http_routes(openapi_spec) + + # Process routes + route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS + for route in http_routes: + route_map = _determine_route_type(route, route_maps) + route_type = route_map.mcp_type + + if route_map_fn is not None: + try: + result = route_map_fn(route, route_type) + if result is not None: + route_type = result + logger.debug( + f"Route {route.method} {route.path} mapping customized: " + f"type={route_type.name}" + ) + except Exception as e: + logger.warning( + f"Error in route_map_fn for {route.method} {route.path}: {e}. " + f"Using default values." + ) + + component_name = self._generate_default_name(route, mcp_names) + route_tags = set(route.tags) | route_map.mcp_tags | (tags or set()) + + if route_type == MCPType.TOOL: + self._create_openapi_tool(route, component_name, tags=route_tags) + elif route_type == MCPType.RESOURCE: + self._create_openapi_resource(route, component_name, tags=route_tags) + elif route_type == MCPType.RESOURCE_TEMPLATE: + self._create_openapi_template(route, component_name, tags=route_tags) + elif route_type == MCPType.EXCLUDE: + logger.debug(f"Excluding route: {route.method} {route.path}") + + logger.debug(f"Created OpenAPIProvider with {len(http_routes)} routes") + + @classmethod + def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient: + """Create a default httpx client from the OpenAPI spec's server URL.""" + return httpx.AsyncClient( + base_url=resolve_spec_base_url(openapi_spec), + timeout=DEFAULT_TIMEOUT, + ) + + @asynccontextmanager + async def lifespan(self) -> AsyncIterator[None]: + """Manage the lifecycle of the auto-created httpx client.""" + if self._owns_client: + async with self._client: + yield + else: + yield + + def _generate_default_name( + self, route: HTTPRoute, mcp_names_map: dict[str, str] | None = None + ) -> str: + """Generate a default name from the route.""" + mcp_names_map = mcp_names_map or {} + + if route.operation_id: + if route.operation_id in mcp_names_map: + name = mcp_names_map[route.operation_id] + else: + name = route.operation_id.split("__")[0] + else: + name = route.summary or f"{route.method}_{route.path}" + + name = _slugify(name) + + if len(name) > 56: + name = name[:56] + + return name + + def _get_unique_name( + self, + name: str, + component_type: Literal["tool", "resource", "resource_template", "prompt"], + ) -> str: + """Ensure the name is unique by appending numbers if needed.""" + self._used_names[component_type][name] += 1 + if self._used_names[component_type][name] == 1: + return name + + new_name = f"{name}_{self._used_names[component_type][name]}" + logger.debug( + f"Name collision: '{name}' exists as {component_type}. Using '{new_name}'." + ) + return new_name + + def _create_openapi_tool( + self, + route: HTTPRoute, + name: str, + tags: set[str], + ) -> None: + """Create and register an OpenAPITool.""" + combined_schema = route.flat_param_schema + output_schema = extract_output_schema_from_responses( + route.responses, + route.response_schemas, + route.openapi_version, + ) + + if not self._validate_output and output_schema is not None: + # Use a permissive schema that accepts any object, preserving + # the wrap-result flag so non-object responses still get wrapped + permissive: dict[str, Any] = { + "type": "object", + "additionalProperties": True, + } + if output_schema.get("x-fastmcp-wrap-result"): + permissive["x-fastmcp-wrap-result"] = True + output_schema = permissive + + tool_name = self._get_unique_name(name, "tool") + base_description = ( + route.description + or route.summary + or f"Executes {route.method} {route.path}" + ) + + tool = OpenAPITool( + client=self._client, + route=route, + director=self._director, + name=tool_name, + description=base_description, + parameters=combined_schema, + output_schema=output_schema, + tags=set(route.tags or []) | tags, + ) + + if self._mcp_component_fn is not None: + try: + self._mcp_component_fn(route, tool) + logger.debug(f"Tool {tool_name} customized by component_fn") + except Exception as e: + logger.warning(f"Error in component_fn for tool {tool_name}: {e}") + + self._tools[tool.name] = tool + + def _create_openapi_resource( + self, + route: HTTPRoute, + name: str, + tags: set[str], + ) -> None: + """Create and register an OpenAPIResource.""" + resource_name = self._get_unique_name(name, "resource") + resource_uri = f"resource://{resource_name}" + base_description = ( + route.description or route.summary or f"Represents {route.path}" + ) + + resource = OpenAPIResource( + client=self._client, + route=route, + director=self._director, + uri=resource_uri, + name=resource_name, + description=base_description, + mime_type=_extract_mime_type_from_route(route), + tags=set(route.tags or []) | tags, + ) + + if self._mcp_component_fn is not None: + try: + self._mcp_component_fn(route, resource) + logger.debug(f"Resource {resource_uri} customized by component_fn") + except Exception as e: + logger.warning( + f"Error in component_fn for resource {resource_uri}: {e}" + ) + + self._resources[str(resource.uri)] = resource + + def _create_openapi_template( + self, + route: HTTPRoute, + name: str, + tags: set[str], + ) -> None: + """Create and register an OpenAPIResourceTemplate.""" + template_name = self._get_unique_name(name, "resource_template") + + path_params = sorted(p.name for p in route.parameters if p.location == "path") + uri_template_str = f"resource://{template_name}" + if path_params: + uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params) + + base_description = ( + route.description or route.summary or f"Template for {route.path}" + ) + + template_params_schema = { + "type": "object", + "properties": { + p.name: { + **(p.schema_.copy() if isinstance(p.schema_, dict) else {}), + **( + {"description": p.description} + if p.description + and not ( + isinstance(p.schema_, dict) and "description" in p.schema_ + ) + else {} + ), + } + for p in route.parameters + if p.location == "path" + }, + "required": [ + p.name for p in route.parameters if p.location == "path" and p.required + ], + } + + template = OpenAPIResourceTemplate( + client=self._client, + route=route, + director=self._director, + uri_template=uri_template_str, + name=template_name, + description=base_description, + parameters=template_params_schema, + tags=set(route.tags or []) | tags, + mime_type=_extract_mime_type_from_route(route), + ) + + if self._mcp_component_fn is not None: + try: + self._mcp_component_fn(route, template) + logger.debug(f"Template {uri_template_str} customized by component_fn") + except Exception as e: + logger.warning( + f"Error in component_fn for template {uri_template_str}: {e}" + ) + + self._templates[template.uri_template] = template + + # ------------------------------------------------------------------------- + # Provider interface + # ------------------------------------------------------------------------- + + async def _list_tools(self) -> Sequence[Tool]: + """Return all tools created from the OpenAPI spec.""" + return list(self._tools.values()) + + async def _get_tool( + self, name: str, version: VersionSpec | None = None + ) -> Tool | None: + """Get a tool by name.""" + tool = self._tools.get(name) + if tool is None: + return None + if version is not None and not version.matches(tool.version): + return None + return tool + + async def _list_resources(self) -> Sequence[Resource]: + """Return all resources created from the OpenAPI spec.""" + return list(self._resources.values()) + + async def _get_resource( + self, uri: str, version: VersionSpec | None = None + ) -> Resource | None: + """Get a resource by URI.""" + resource = self._resources.get(uri) + if resource is None: + return None + if version is not None and not version.matches(resource.version): + return None + return resource + + async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: + """Return all resource templates created from the OpenAPI spec.""" + return list(self._templates.values()) + + async def _get_resource_template( + self, uri: str, version: VersionSpec | None = None + ) -> ResourceTemplate | None: + """Get a resource template that matches the given URI.""" + matching = [t for t in self._templates.values() if t.matches(uri) is not None] + if not matching: + return None + if version is not None: + matching = [t for t in matching if version.matches(t.version)] + if not matching: + return None + return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + + async def _list_prompts(self) -> Sequence[Prompt]: + """Return empty list - OpenAPI doesn't create prompts.""" + return [] + + async def get_tasks(self) -> Sequence[FastMCPComponent]: + """Return empty list - OpenAPI components don't support tasks.""" + return [] diff --git a/src/fastmcp/server/plugins/openapi/routing.py b/src/fastmcp/server/plugins/openapi/routing.py new file mode 100644 index 000000000..09a4995e5 --- /dev/null +++ b/src/fastmcp/server/plugins/openapi/routing.py @@ -0,0 +1,109 @@ +"""Route mapping logic for OpenAPI operations.""" + +from __future__ import annotations + +import enum +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from re import Pattern +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from fastmcp.server.plugins.openapi.components import ( + OpenAPIResource, + OpenAPIResourceTemplate, + OpenAPITool, + ) + +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.openapi import HttpMethod, HTTPRoute + +__all__ = [ + "ComponentFn", + "MCPType", + "RouteMap", + "RouteMapFn", +] + +logger = get_logger(__name__) + +# Type definitions for the mapping functions +RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"] +ComponentFn = Callable[ + [ + HTTPRoute, + "OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate", + ], + None, +] + + +class MCPType(enum.Enum): + """Type of FastMCP component to create from a route. + + Enum values: + TOOL: Convert the route to a callable Tool + RESOURCE: Convert the route to a Resource (typically GET endpoints) + RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params) + EXCLUDE: Exclude the route from being converted to any MCP component + """ + + TOOL = "TOOL" + RESOURCE = "RESOURCE" + RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" + EXCLUDE = "EXCLUDE" + + +@dataclass(kw_only=True) +class RouteMap: + """Mapping configuration for HTTP routes to FastMCP component types.""" + + methods: list[HttpMethod] | Literal["*"] = field(default="*") + pattern: Pattern[str] | str = field(default=r".*") + + tags: set[str] = field( + default_factory=set, + metadata={"description": "A set of tags to match. All tags must match."}, + ) + mcp_type: MCPType = field( + metadata={"description": "The type of FastMCP component to create."}, + ) + mcp_tags: set[str] = field( + default_factory=set, + metadata={ + "description": "A set of tags to apply to the generated FastMCP component." + }, + ) + + +# Default route mapping: all routes become tools. +DEFAULT_ROUTE_MAPPINGS = [ + RouteMap(mcp_type=MCPType.TOOL), +] + + +def _determine_route_type( + route: HTTPRoute, + mappings: list[RouteMap], +) -> RouteMap: + """Determine the FastMCP component type based on the route and mappings.""" + for route_map in mappings: + if route_map.methods == "*" or route.method in route_map.methods: + if isinstance(route_map.pattern, Pattern): + pattern_matches = route_map.pattern.search(route.path) + else: + pattern_matches = re.search(route_map.pattern, route.path) + + if pattern_matches: + if route_map.tags: + route_tags_set = set(route.tags or []) + if not route_map.tags.issubset(route_tags_set): + continue + + logger.debug( + f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}" + ) + return route_map + + return RouteMap(mcp_type=MCPType.TOOL) diff --git a/src/fastmcp/server/providers/openapi/__init__.py b/src/fastmcp/server/providers/openapi/__init__.py index 3cbdde5e8..d90227657 100644 --- a/src/fastmcp/server/providers/openapi/__init__.py +++ b/src/fastmcp/server/providers/openapi/__init__.py @@ -1,26 +1,32 @@ -"""OpenAPI provider for FastMCP. +"""Backwards-compatibility shim — OpenAPI moved to `fastmcp.server.plugins.openapi`. -This module provides OpenAPI integration for FastMCP through the Provider pattern. +The preferred entry point is now the `OpenAPI` plugin: -Example: - ```python from fastmcp import FastMCP - from fastmcp.server.providers.openapi import OpenAPIProvider - import httpx + from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig - client = httpx.AsyncClient(base_url="https://api.example.com") - provider = OpenAPIProvider(openapi_spec=spec, client=client) - mcp = FastMCP("API Server", providers=[provider]) - ``` + mcp = FastMCP("Server", plugins=[OpenAPI(OpenAPIConfig(spec=...))]) + +`OpenAPIProvider` and its helpers (`RouteMap`, `MCPType`, component +classes) remain importable from this package for direct composition. +Importing from this top-level path does **not** emit a deprecation +warning — it stays silent so that unrelated code in fastmcp that +happens to touch `fastmcp.server.providers.openapi` doesn't spray +warnings. Users who import from the leaf submodules (`.provider`, +`.routing`, `.components`) directly will see a `FastMCPDeprecationWarning` +pointing at the new location. """ -from fastmcp.server.providers.openapi.components import ( +# Silent passthrough at the package level — re-export from the new +# location directly so neither this import nor the lazy provider import +# inside `fastmcp.server.providers.__init__` fires a deprecation warning. +from fastmcp.server.plugins.openapi.components import ( OpenAPIResource, OpenAPIResourceTemplate, OpenAPITool, ) -from fastmcp.server.providers.openapi.provider import OpenAPIProvider -from fastmcp.server.providers.openapi.routing import ( +from fastmcp.server.plugins.openapi.provider import OpenAPIProvider +from fastmcp.server.plugins.openapi.routing import ( ComponentFn, MCPType, RouteMap, diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py index 5d8cee1f4..18ee9477b 100644 --- a/src/fastmcp/server/providers/openapi/components.py +++ b/src/fastmcp/server/providers/openapi/components.py @@ -1,53 +1,25 @@ -"""OpenAPI component classes: Tool, Resource, and ResourceTemplate.""" +"""Deprecation shim — OpenAPI component classes moved to +`fastmcp.server.plugins.openapi.components`. +""" -from __future__ import annotations - -import json -import re import warnings -from collections.abc import Callable -from typing import TYPE_CHECKING, Any -import httpx -from mcp.types import ToolAnnotations -from pydantic.networks import AnyUrl - -import fastmcp from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.resources import ( - Resource, - ResourceContent, - ResourceResult, - ResourceTemplate, -) -from fastmcp.server.dependencies import get_http_headers -from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.openapi import HTTPRoute -from fastmcp.utilities.openapi.director import RequestDirector - -if TYPE_CHECKING: - from fastmcp.server import Context - -_SAFE_HEADERS = frozenset( - { - "accept", - "accept-encoding", - "accept-language", - "cache-control", - "connection", - "content-length", - "content-type", - "host", - "user-agent", - } +from fastmcp.server.plugins.openapi.components import ( + OpenAPIResource, + OpenAPIResourceTemplate, + OpenAPITool, + _extract_mime_type_from_route, ) - -def _redact_headers(headers: httpx.Headers) -> dict[str, str]: - return {k: v if k.lower() in _SAFE_HEADERS else "***" for k, v in headers.items()} - +warnings.warn( + "fastmcp.server.providers.openapi.components has moved to " + "fastmcp.server.plugins.openapi.components. Prefer the OpenAPI " + "plugin: `from fastmcp.server.plugins.openapi import OpenAPI`. This " + "old leaf-submodule import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, +) __all__ = [ "OpenAPIResource", @@ -55,367 +27,3 @@ __all__ = [ "OpenAPITool", "_extract_mime_type_from_route", ] - -logger = get_logger(__name__) - -# Default MIME type when no response content type can be inferred -_DEFAULT_MIME_TYPE = "application/json" - - -def _extract_mime_type_from_route(route: HTTPRoute) -> str: - """Extract the primary MIME type from an HTTPRoute's response definitions. - - Looks for the first successful response (2xx) and returns its content type. - Prefers JSON-compatible types when multiple are available. - Falls back to "application/json" when no response content type is declared. - """ - if not route.responses: - return _DEFAULT_MIME_TYPE - - # Priority order for success status codes - success_codes = ["200", "201", "202", "204"] - - response_info = None - for status_code in success_codes: - if status_code in route.responses: - response_info = route.responses[status_code] - break - - # If no explicit success codes, try any 2xx response - if response_info is None: - for status_code, resp_info in route.responses.items(): - if status_code.startswith("2"): - response_info = resp_info - break - - if response_info is None or not response_info.content_schema: - return _DEFAULT_MIME_TYPE - - # If there's only one content type, use it directly - content_types = list(response_info.content_schema.keys()) - if len(content_types) == 1: - return content_types[0] - - # When multiple types exist, prefer JSON-compatible types - json_compatible_types = [ - "application/json", - "application/vnd.api+json", - "application/hal+json", - "application/ld+json", - "text/json", - ] - for ct in json_compatible_types: - if ct in response_info.content_schema: - return ct - - # Fall back to the first available content type - return content_types[0] - - -def _slugify(text: str) -> str: - """Convert text to a URL-friendly slug format. - - Only contains lowercase letters, uppercase letters, numbers, and underscores. - """ - if not text: - return "" - - # Replace spaces and common separators with underscores - slug = re.sub(r"[\s\-\.]+", "_", text) - - # Remove non-alphanumeric characters except underscores - slug = re.sub(r"[^a-zA-Z0-9_]", "", slug) - - # Remove multiple consecutive underscores - slug = re.sub(r"_+", "_", slug) - - # Remove leading/trailing underscores - slug = slug.strip("_") - - return slug - - -class OpenAPITool(Tool): - """Tool implementation for OpenAPI endpoints.""" - - task_config: TaskConfig = TaskConfig(mode="forbidden") - - def __init__( - self, - client: httpx.AsyncClient, - route: HTTPRoute, - director: RequestDirector, - name: str, - description: str, - parameters: dict[str, Any], - output_schema: dict[str, Any] | None = None, - tags: set[str] | None = None, - annotations: ToolAnnotations | None = None, - serializer: Callable[[Any], str] | None = None, # Deprecated - ): - if serializer is not None and fastmcp.settings.deprecation_warnings: - warnings.warn( - "The `serializer` parameter is deprecated. " - "Return ToolResult from your tools for full control over serialization. " - "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - super().__init__( - name=name, - description=description, - parameters=parameters, - output_schema=output_schema, - tags=tags or set(), - annotations=annotations, - serializer=serializer, - ) - self._client = client - self._route = route - self._director = director - - def __repr__(self) -> str: - return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})" - - async def run(self, arguments: dict[str, Any]) -> ToolResult: - """Execute the HTTP request using RequestDirector.""" - # Build the request — errors here are programming/schema issues, - # not HTTP failures, so we catch them separately. - try: - base_url = str(self._client.base_url) or "http://localhost" - request = self._director.build(self._route, arguments, base_url) - - if self._client.headers: - for key, value in self._client.headers.items(): - if key not in request.headers: - request.headers[key] = value - - mcp_headers = get_http_headers() - if mcp_headers: - for key, value in mcp_headers.items(): - if key not in request.headers: - request.headers[key] = value - except Exception as e: - raise ValueError( - f"Error building request for {self._route.method.upper()} " - f"{self._route.path}: {type(e).__name__}: {e}" - ) from e - - # Send the request and process the response. - try: - logger.debug( - f"run - sending request; headers: {_redact_headers(request.headers)}" - ) - - response = await self._client.send(request) - response.raise_for_status() - - # Try to parse as JSON first - try: - result = response.json() - - # Handle structured content based on output schema - if self.output_schema is not None: - if self.output_schema.get("x-fastmcp-wrap-result"): - structured_output = {"result": result} - else: - structured_output = result - elif not isinstance(result, dict): - structured_output = {"result": result} - else: - structured_output = result - - # Structured content must be a dict for the MCP protocol. - # Wrap non-dict values that slipped through (e.g. a backend - # returning an array when the schema declared an object). - if not isinstance(structured_output, dict): - structured_output = {"result": structured_output} - - return ToolResult(structured_content=structured_output) - except json.JSONDecodeError: - return ToolResult(content=response.text) - - except httpx.HTTPStatusError as e: - error_message = ( - f"HTTP error {e.response.status_code}: {e.response.reason_phrase}" - ) - try: - error_data = e.response.json() - error_message += f" - {error_data}" - except (json.JSONDecodeError, ValueError): - if e.response.text: - error_message += f" - {e.response.text}" - raise ValueError(error_message) from e - - except httpx.TimeoutException as e: - raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e - - except httpx.RequestError as e: - raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e - - -class OpenAPIResource(Resource): - """Resource implementation for OpenAPI endpoints.""" - - task_config: TaskConfig = TaskConfig(mode="forbidden") - - def __init__( - self, - client: httpx.AsyncClient, - route: HTTPRoute, - director: RequestDirector, - uri: str, - name: str, - description: str, - mime_type: str = "application/json", - tags: set[str] | None = None, - ): - super().__init__( - uri=AnyUrl(uri), - name=name, - description=description, - mime_type=mime_type, - tags=tags or set(), - ) - self._client = client - self._route = route - self._director = director - - def __repr__(self) -> str: - return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})" - - async def read(self) -> ResourceResult: - """Fetch the resource data by making an HTTP request.""" - try: - path = self._route.path - resource_uri = str(self.uri) - - # If this is a templated resource, extract path parameters from the URI - if "{" in path and "}" in path: - parts = resource_uri.split("/") - - if len(parts) > 1: - path_params = {} - param_matches = re.findall(r"\{([^}]+)\}", path) - if param_matches: - param_matches.sort(reverse=True) - expected_param_count = len(parts) - 1 - for i, param_name in enumerate(param_matches): - if i < expected_param_count: - param_value = parts[-1 - i] - path_params[param_name] = param_value - - for param_name, param_value in path_params.items(): - path = path.replace(f"{{{param_name}}}", str(param_value)) - - # Build headers with correct precedence - headers: dict[str, str] = {} - if self._client.headers: - headers.update(self._client.headers) - mcp_headers = get_http_headers() - if mcp_headers: - headers.update(mcp_headers) - - response = await self._client.request( - method=self._route.method, - url=path, - headers=headers, - ) - response.raise_for_status() - - content_type = response.headers.get("content-type", "").lower() - - if "application/json" in content_type: - result = response.json() - return ResourceResult( - contents=[ - ResourceContent( - content=json.dumps(result), mime_type="application/json" - ) - ] - ) - elif any(ct in content_type for ct in ["text/", "application/xml"]): - return ResourceResult( - contents=[ - ResourceContent(content=response.text, mime_type=self.mime_type) - ] - ) - else: - return ResourceResult( - contents=[ - ResourceContent( - content=response.content, mime_type=self.mime_type - ) - ] - ) - - except httpx.HTTPStatusError as e: - error_message = ( - f"HTTP error {e.response.status_code}: {e.response.reason_phrase}" - ) - try: - error_data = e.response.json() - error_message += f" - {error_data}" - except (json.JSONDecodeError, ValueError): - if e.response.text: - error_message += f" - {e.response.text}" - raise ValueError(error_message) from e - - except httpx.TimeoutException as e: - raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e - - except httpx.RequestError as e: - raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e - - -class OpenAPIResourceTemplate(ResourceTemplate): - """Resource template implementation for OpenAPI endpoints.""" - - task_config: TaskConfig = TaskConfig(mode="forbidden") - - def __init__( - self, - client: httpx.AsyncClient, - route: HTTPRoute, - director: RequestDirector, - uri_template: str, - name: str, - description: str, - parameters: dict[str, Any], - tags: set[str] | None = None, - mime_type: str = _DEFAULT_MIME_TYPE, - ): - super().__init__( - uri_template=uri_template, - name=name, - description=description, - parameters=parameters, - tags=tags or set(), - mime_type=mime_type, - ) - self._client = client - self._route = route - self._director = director - - def __repr__(self) -> str: - return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})" - - async def create_resource( - self, - uri: str, - params: dict[str, Any], - context: Context | None = None, - ) -> Resource: - """Create a resource with the given parameters.""" - uri_parts = [f"{key}={value}" for key, value in params.items()] - - return OpenAPIResource( - client=self._client, - route=self._route, - director=self._director, - uri=uri, - name=f"{self.name}-{'-'.join(uri_parts)}", - description=self.description or f"Resource for {self._route.path}", - mime_type=self.mime_type, - tags=set(self._route.tags or []), - ) diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py index dd9a73559..49160d651 100644 --- a/src/fastmcp/server/providers/openapi/provider.py +++ b/src/fastmcp/server/providers/openapi/provider.py @@ -1,436 +1,23 @@ -"""OpenAPIProvider for creating MCP components from OpenAPI specifications.""" +"""Deprecation shim — `OpenAPIProvider` moved to +`fastmcp.server.plugins.openapi.provider`. -from __future__ import annotations +Prefer the `OpenAPI` plugin at `fastmcp.server.plugins.openapi` for new +code. `OpenAPIProvider` is still importable here for backcompat with +callers that composed it directly. +""" -from collections import Counter -from collections.abc import AsyncIterator, Sequence -from contextlib import asynccontextmanager -from typing import Any, Literal, cast +import warnings -import httpx -from jsonschema_path import SchemaPath +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.openapi.provider import OpenAPIProvider -from fastmcp.prompts import Prompt -from fastmcp.resources import Resource, ResourceTemplate -from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.openapi.components import ( - OpenAPIResource, - OpenAPIResourceTemplate, - OpenAPITool, - _extract_mime_type_from_route, - _slugify, +warnings.warn( + "fastmcp.server.providers.openapi.provider has moved to " + "fastmcp.server.plugins.openapi.provider. Prefer the OpenAPI plugin: " + "`from fastmcp.server.plugins.openapi import OpenAPI`. This old " + "leaf-submodule import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, ) -from fastmcp.server.providers.openapi.routing import ( - DEFAULT_ROUTE_MAPPINGS, - ComponentFn, - MCPType, - RouteMap, - RouteMapFn, - _determine_route_type, -) -from fastmcp.tools.base import Tool -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.openapi import ( - HTTPRoute, - extract_output_schema_from_responses, - parse_openapi_to_http_routes, -) -from fastmcp.utilities.openapi.director import RequestDirector -from fastmcp.utilities.versions import VersionSpec, version_sort_key -__all__ = [ - "OpenAPIProvider", -] - -logger = get_logger(__name__) - -DEFAULT_TIMEOUT: float = 30.0 - - -class OpenAPIProvider(Provider): - """Provider that creates MCP components from an OpenAPI specification. - - Components are created eagerly during initialization by parsing the OpenAPI - spec. Each component makes HTTP calls to the described API endpoints. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers.openapi import OpenAPIProvider - import httpx - - client = httpx.AsyncClient(base_url="https://api.example.com") - provider = OpenAPIProvider(openapi_spec=spec, client=client) - - mcp = FastMCP("API Server") - mcp.add_provider(provider) - ``` - """ - - def __init__( - self, - openapi_spec: dict[str, Any], - client: httpx.AsyncClient | None = None, - *, - route_maps: list[RouteMap] | None = None, - route_map_fn: RouteMapFn | None = None, - mcp_component_fn: ComponentFn | None = None, - mcp_names: dict[str, str] | None = None, - tags: set[str] | None = None, - validate_output: bool = True, - ): - """Initialize provider by parsing OpenAPI spec and creating components. - - Args: - openapi_spec: OpenAPI schema as a dictionary - client: Optional httpx AsyncClient for making HTTP requests. - If not provided, a default client is created using the first - server URL from the OpenAPI spec with a 30-second timeout. - To customize timeout or other settings, pass your own client. - route_maps: Optional list of RouteMap objects defining route mappings - route_map_fn: Optional callable for advanced route type mapping - mcp_component_fn: Optional callable for component customization - mcp_names: Optional dictionary mapping operationId to component names - tags: Optional set of tags to add to all components - validate_output: If True (default), tools use the output schema - extracted from the OpenAPI spec for response validation. If - False, a permissive schema is used instead, allowing any - response structure while still returning structured JSON. - """ - super().__init__() - - self._owns_client = client is None - if client is None: - client = self._create_default_client(openapi_spec) - self._client = client - self._mcp_component_fn = mcp_component_fn - self._validate_output = validate_output - - # Keep track of names to detect collisions - self._used_names: dict[str, Counter[str]] = { - "tool": Counter(), - "resource": Counter(), - "resource_template": Counter(), - "prompt": Counter(), - } - - # Pre-created component storage - self._tools: dict[str, OpenAPITool] = {} - self._resources: dict[str, OpenAPIResource] = {} - self._templates: dict[str, OpenAPIResourceTemplate] = {} - - # Create openapi-core Spec and RequestDirector - try: - self._spec = SchemaPath.from_dict(cast(Any, openapi_spec)) - self._director = RequestDirector(self._spec) - except Exception as e: - logger.exception("Failed to initialize RequestDirector") - raise ValueError(f"Invalid OpenAPI specification: {e}") from e - - http_routes = parse_openapi_to_http_routes(openapi_spec) - - # Process routes - route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS - for route in http_routes: - route_map = _determine_route_type(route, route_maps) - route_type = route_map.mcp_type - - if route_map_fn is not None: - try: - result = route_map_fn(route, route_type) - if result is not None: - route_type = result - logger.debug( - f"Route {route.method} {route.path} mapping customized: " - f"type={route_type.name}" - ) - except Exception as e: - logger.warning( - f"Error in route_map_fn for {route.method} {route.path}: {e}. " - f"Using default values." - ) - - component_name = self._generate_default_name(route, mcp_names) - route_tags = set(route.tags) | route_map.mcp_tags | (tags or set()) - - if route_type == MCPType.TOOL: - self._create_openapi_tool(route, component_name, tags=route_tags) - elif route_type == MCPType.RESOURCE: - self._create_openapi_resource(route, component_name, tags=route_tags) - elif route_type == MCPType.RESOURCE_TEMPLATE: - self._create_openapi_template(route, component_name, tags=route_tags) - elif route_type == MCPType.EXCLUDE: - logger.debug(f"Excluding route: {route.method} {route.path}") - - logger.debug(f"Created OpenAPIProvider with {len(http_routes)} routes") - - @classmethod - def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient: - """Create a default httpx client from the OpenAPI spec's server URL.""" - servers = openapi_spec.get("servers", []) - if not servers or not servers[0].get("url"): - raise ValueError( - "No server URL found in OpenAPI spec. Either add a 'servers' " - "entry to the spec or provide an httpx.AsyncClient explicitly." - ) - base_url = servers[0]["url"] - variables = servers[0].get("variables", {}) - for name, var in variables.items(): - base_url = base_url.replace(f"{{{name}}}", var.get("default", "")) - return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT) - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - """Manage the lifecycle of the auto-created httpx client.""" - if self._owns_client: - async with self._client: - yield - else: - yield - - def _generate_default_name( - self, route: HTTPRoute, mcp_names_map: dict[str, str] | None = None - ) -> str: - """Generate a default name from the route.""" - mcp_names_map = mcp_names_map or {} - - if route.operation_id: - if route.operation_id in mcp_names_map: - name = mcp_names_map[route.operation_id] - else: - name = route.operation_id.split("__")[0] - else: - name = route.summary or f"{route.method}_{route.path}" - - name = _slugify(name) - - if len(name) > 56: - name = name[:56] - - return name - - def _get_unique_name( - self, - name: str, - component_type: Literal["tool", "resource", "resource_template", "prompt"], - ) -> str: - """Ensure the name is unique by appending numbers if needed.""" - self._used_names[component_type][name] += 1 - if self._used_names[component_type][name] == 1: - return name - - new_name = f"{name}_{self._used_names[component_type][name]}" - logger.debug( - f"Name collision: '{name}' exists as {component_type}. Using '{new_name}'." - ) - return new_name - - def _create_openapi_tool( - self, - route: HTTPRoute, - name: str, - tags: set[str], - ) -> None: - """Create and register an OpenAPITool.""" - combined_schema = route.flat_param_schema - output_schema = extract_output_schema_from_responses( - route.responses, - route.response_schemas, - route.openapi_version, - ) - - if not self._validate_output and output_schema is not None: - # Use a permissive schema that accepts any object, preserving - # the wrap-result flag so non-object responses still get wrapped - permissive: dict[str, Any] = { - "type": "object", - "additionalProperties": True, - } - if output_schema.get("x-fastmcp-wrap-result"): - permissive["x-fastmcp-wrap-result"] = True - output_schema = permissive - - tool_name = self._get_unique_name(name, "tool") - base_description = ( - route.description - or route.summary - or f"Executes {route.method} {route.path}" - ) - - tool = OpenAPITool( - client=self._client, - route=route, - director=self._director, - name=tool_name, - description=base_description, - parameters=combined_schema, - output_schema=output_schema, - tags=set(route.tags or []) | tags, - ) - - if self._mcp_component_fn is not None: - try: - self._mcp_component_fn(route, tool) - logger.debug(f"Tool {tool_name} customized by component_fn") - except Exception as e: - logger.warning(f"Error in component_fn for tool {tool_name}: {e}") - - self._tools[tool.name] = tool - - def _create_openapi_resource( - self, - route: HTTPRoute, - name: str, - tags: set[str], - ) -> None: - """Create and register an OpenAPIResource.""" - resource_name = self._get_unique_name(name, "resource") - resource_uri = f"resource://{resource_name}" - base_description = ( - route.description or route.summary or f"Represents {route.path}" - ) - - resource = OpenAPIResource( - client=self._client, - route=route, - director=self._director, - uri=resource_uri, - name=resource_name, - description=base_description, - mime_type=_extract_mime_type_from_route(route), - tags=set(route.tags or []) | tags, - ) - - if self._mcp_component_fn is not None: - try: - self._mcp_component_fn(route, resource) - logger.debug(f"Resource {resource_uri} customized by component_fn") - except Exception as e: - logger.warning( - f"Error in component_fn for resource {resource_uri}: {e}" - ) - - self._resources[str(resource.uri)] = resource - - def _create_openapi_template( - self, - route: HTTPRoute, - name: str, - tags: set[str], - ) -> None: - """Create and register an OpenAPIResourceTemplate.""" - template_name = self._get_unique_name(name, "resource_template") - - path_params = sorted(p.name for p in route.parameters if p.location == "path") - uri_template_str = f"resource://{template_name}" - if path_params: - uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params) - - base_description = ( - route.description or route.summary or f"Template for {route.path}" - ) - - template_params_schema = { - "type": "object", - "properties": { - p.name: { - **(p.schema_.copy() if isinstance(p.schema_, dict) else {}), - **( - {"description": p.description} - if p.description - and not ( - isinstance(p.schema_, dict) and "description" in p.schema_ - ) - else {} - ), - } - for p in route.parameters - if p.location == "path" - }, - "required": [ - p.name for p in route.parameters if p.location == "path" and p.required - ], - } - - template = OpenAPIResourceTemplate( - client=self._client, - route=route, - director=self._director, - uri_template=uri_template_str, - name=template_name, - description=base_description, - parameters=template_params_schema, - tags=set(route.tags or []) | tags, - mime_type=_extract_mime_type_from_route(route), - ) - - if self._mcp_component_fn is not None: - try: - self._mcp_component_fn(route, template) - logger.debug(f"Template {uri_template_str} customized by component_fn") - except Exception as e: - logger.warning( - f"Error in component_fn for template {uri_template_str}: {e}" - ) - - self._templates[template.uri_template] = template - - # ------------------------------------------------------------------------- - # Provider interface - # ------------------------------------------------------------------------- - - async def _list_tools(self) -> Sequence[Tool]: - """Return all tools created from the OpenAPI spec.""" - return list(self._tools.values()) - - async def _get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name.""" - tool = self._tools.get(name) - if tool is None: - return None - if version is not None and not version.matches(tool.version): - return None - return tool - - async def _list_resources(self) -> Sequence[Resource]: - """Return all resources created from the OpenAPI spec.""" - return list(self._resources.values()) - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get a resource by URI.""" - resource = self._resources.get(uri) - if resource is None: - return None - if version is not None and not version.matches(resource.version): - return None - return resource - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """Return all resource templates created from the OpenAPI spec.""" - return list(self._templates.values()) - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get a resource template that matches the given URI.""" - matching = [t for t in self._templates.values() if t.matches(uri) is not None] - if not matching: - return None - if version is not None: - matching = [t for t in matching if version.matches(t.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - async def _list_prompts(self) -> Sequence[Prompt]: - """Return empty list - OpenAPI doesn't create prompts.""" - return [] - - async def get_tasks(self) -> Sequence[FastMCPComponent]: - """Return empty list - OpenAPI components don't support tasks.""" - return [] +__all__ = ["OpenAPIProvider"] diff --git a/src/fastmcp/server/providers/openapi/routing.py b/src/fastmcp/server/providers/openapi/routing.py index 7805f0011..72af6dbc6 100644 --- a/src/fastmcp/server/providers/openapi/routing.py +++ b/src/fastmcp/server/providers/openapi/routing.py @@ -1,23 +1,25 @@ -"""Route mapping logic for OpenAPI operations.""" +"""Deprecation shim — OpenAPI route-mapping types moved to +`fastmcp.server.plugins.openapi.routing`. +""" -from __future__ import annotations +import warnings -import enum -import re -from collections.abc import Callable -from dataclasses import dataclass, field -from re import Pattern -from typing import TYPE_CHECKING, Literal +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.openapi.routing import ( + ComponentFn, + MCPType, + RouteMap, + RouteMapFn, +) -if TYPE_CHECKING: - from fastmcp.server.providers.openapi.components import ( - OpenAPIResource, - OpenAPIResourceTemplate, - OpenAPITool, - ) - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.openapi import HttpMethod, HTTPRoute +warnings.warn( + "fastmcp.server.providers.openapi.routing has moved to " + "fastmcp.server.plugins.openapi.routing. Prefer the OpenAPI plugin: " + "`from fastmcp.server.plugins.openapi import OpenAPI`. This old " + "leaf-submodule import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, +) __all__ = [ "ComponentFn", @@ -25,85 +27,3 @@ __all__ = [ "RouteMap", "RouteMapFn", ] - -logger = get_logger(__name__) - -# Type definitions for the mapping functions -RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"] -ComponentFn = Callable[ - [ - HTTPRoute, - "OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate", - ], - None, -] - - -class MCPType(enum.Enum): - """Type of FastMCP component to create from a route. - - Enum values: - TOOL: Convert the route to a callable Tool - RESOURCE: Convert the route to a Resource (typically GET endpoints) - RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params) - EXCLUDE: Exclude the route from being converted to any MCP component - """ - - TOOL = "TOOL" - RESOURCE = "RESOURCE" - RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" - EXCLUDE = "EXCLUDE" - - -@dataclass(kw_only=True) -class RouteMap: - """Mapping configuration for HTTP routes to FastMCP component types.""" - - methods: list[HttpMethod] | Literal["*"] = field(default="*") - pattern: Pattern[str] | str = field(default=r".*") - - tags: set[str] = field( - default_factory=set, - metadata={"description": "A set of tags to match. All tags must match."}, - ) - mcp_type: MCPType = field( - metadata={"description": "The type of FastMCP component to create."}, - ) - mcp_tags: set[str] = field( - default_factory=set, - metadata={ - "description": "A set of tags to apply to the generated FastMCP component." - }, - ) - - -# Default route mapping: all routes become tools. -DEFAULT_ROUTE_MAPPINGS = [ - RouteMap(mcp_type=MCPType.TOOL), -] - - -def _determine_route_type( - route: HTTPRoute, - mappings: list[RouteMap], -) -> RouteMap: - """Determine the FastMCP component type based on the route and mappings.""" - for route_map in mappings: - if route_map.methods == "*" or route.method in route_map.methods: - if isinstance(route_map.pattern, Pattern): - pattern_matches = route_map.pattern.search(route.path) - else: - pattern_matches = re.search(route_map.pattern, route.path) - - if pattern_matches: - if route_map.tags: - route_tags_set = set(route.tags or []) - if not route_map.tags.issubset(route_tags_set): - continue - - logger.debug( - f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}" - ) - return route_map - - return RouteMap(mcp_type=MCPType.TOOL) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 7ec2f57f7..75e0a258e 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -98,9 +98,9 @@ if TYPE_CHECKING: from fastmcp.client.client import FastMCP1Server from fastmcp.client.sampling import SamplingHandler from fastmcp.client.transports import ClientTransport, ClientTransportT - from fastmcp.server.providers.openapi import ComponentFn as OpenAPIComponentFn - from fastmcp.server.providers.openapi import RouteMap - from fastmcp.server.providers.openapi import RouteMapFn as OpenAPIRouteMapFn + from fastmcp.server.plugins.openapi import RouteMap + from fastmcp.server.plugins.openapi.routing import ComponentFn as OpenAPIComponentFn + from fastmcp.server.plugins.openapi.routing import RouteMapFn as OpenAPIRouteMapFn from fastmcp.server.providers.proxy import FastMCPProxy logger = get_logger(__name__) @@ -2495,21 +2495,28 @@ class FastMCP( **settings: Additional settings passed to FastMCP Returns: - A FastMCP server with an OpenAPIProvider attached. + A FastMCP server with the OpenAPI plugin attached. """ - from .providers.openapi import OpenAPIProvider + from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig - provider: Provider = OpenAPIProvider( - openapi_spec=openapi_spec, + # `from_openapi` returns an eagerly-populated server (callers + # frequently inspect `list_tools()` before running the server). + # Build the plugin to reuse its config-validation and provider- + # construction logic, then extract the provider eagerly rather + # than deferring to plugin-lifespan contribution. + plugin = OpenAPI( + OpenAPIConfig( + spec=openapi_spec, + mcp_names=mcp_names, + tags=sorted(tags) if tags else [], + validate_output=validate_output, + ), client=client, route_maps=route_maps, route_map_fn=route_map_fn, mcp_component_fn=mcp_component_fn, - mcp_names=mcp_names, - tags=tags, - validate_output=validate_output, ) - return cls(name=name, providers=[provider], **settings) + return cls(name=name, providers=list(plugin.providers()), **settings) @classmethod def from_fastapi( @@ -2540,9 +2547,9 @@ class FastMCP( **settings: Additional settings passed to FastMCP Returns: - A FastMCP server with an OpenAPIProvider attached. + A FastMCP server with the OpenAPI plugin attached. """ - from .providers.openapi import OpenAPIProvider + from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig if httpx_client_kwargs is None: httpx_client_kwargs = {} @@ -2555,16 +2562,18 @@ class FastMCP( server_name = name or app.title - provider: Provider = OpenAPIProvider( - openapi_spec=app.openapi(), + plugin = OpenAPI( + OpenAPIConfig( + spec=app.openapi(), + mcp_names=mcp_names, + tags=sorted(tags) if tags else [], + ), client=client, route_maps=route_maps, route_map_fn=route_map_fn, mcp_component_fn=mcp_component_fn, - mcp_names=mcp_names, - tags=tags, ) - return cls(name=server_name, providers=[provider], **settings) + return cls(name=server_name, providers=list(plugin.providers()), **settings) @classmethod def as_proxy( diff --git a/tests/deprecated/openapi/test_openapi.py b/tests/deprecated/openapi/test_openapi.py index 36ad0b6bf..9dd474f3b 100644 --- a/tests/deprecated/openapi/test_openapi.py +++ b/tests/deprecated/openapi/test_openapi.py @@ -27,7 +27,7 @@ class TestDeprecatedServerOpenAPIImports: x for x in w if issubclass(x.category, DeprecationWarning) ] assert len(deprecation_warnings) >= 1 - assert "providers.openapi" in str(deprecation_warnings[0].message) + assert "plugins.openapi" in str(deprecation_warnings[0].message) def test_import_routing_emits_warning(self): """Importing from fastmcp.server.openapi.routing should emit deprecation warning.""" @@ -43,7 +43,7 @@ class TestDeprecatedServerOpenAPIImports: x for x in w if issubclass(x.category, DeprecationWarning) ] assert len(deprecation_warnings) >= 1 - assert "providers.openapi" in str(deprecation_warnings[0].message) + assert "plugins.openapi" in str(deprecation_warnings[0].message) def test_fastmcp_openapi_class_emits_warning(self): """Using FastMCPOpenAPI should emit deprecation warning.""" @@ -117,7 +117,7 @@ class TestDeprecatedExperimentalOpenAPIImports: x for x in w if issubclass(x.category, DeprecationWarning) ] assert len(deprecation_warnings) >= 1 - assert "providers.openapi" in str(deprecation_warnings[0].message) + assert "plugins.openapi" in str(deprecation_warnings[0].message) def test_experimental_imports_still_work(self): """All expected symbols should be importable from experimental.""" @@ -152,7 +152,7 @@ class TestDeprecatedComponentsImports: x for x in w if issubclass(x.category, DeprecationWarning) ] assert len(deprecation_warnings) >= 1 - assert "providers.openapi" in str(deprecation_warnings[0].message) + assert "plugins.openapi" in str(deprecation_warnings[0].message) def test_components_imports_still_work(self): """Component classes should be importable from deprecated location.""" diff --git a/tests/server/providers/openapi/__init__.py b/tests/server/plugins/openapi/__init__.py similarity index 100% rename from tests/server/providers/openapi/__init__.py rename to tests/server/plugins/openapi/__init__.py diff --git a/tests/server/providers/openapi/test_comprehensive.py b/tests/server/plugins/openapi/test_comprehensive.py similarity index 99% rename from tests/server/providers/openapi/test_comprehensive.py rename to tests/server/plugins/openapi/test_comprehensive.py index 5396767c2..aedc639c0 100644 --- a/tests/server/providers/openapi/test_comprehensive.py +++ b/tests/server/plugins/openapi/test_comprehensive.py @@ -9,7 +9,7 @@ from httpx import Response from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.server.providers.openapi import OpenAPIProvider +from fastmcp.server.plugins.openapi.provider import OpenAPIProvider def create_openapi_server( @@ -929,7 +929,7 @@ class TestOpenAPIPostEdgeCases: async def test_unexpected_error_in_request_building_gives_useful_message(self): """Unexpected exceptions during request building should produce useful errors.""" - from fastmcp.server.providers.openapi.components import OpenAPITool + from fastmcp.server.plugins.openapi.components import OpenAPITool from fastmcp.utilities.openapi.director import RequestDirector from fastmcp.utilities.openapi.models import HTTPRoute diff --git a/tests/server/providers/openapi/test_deepobject_style.py b/tests/server/plugins/openapi/test_deepobject_style.py similarity index 99% rename from tests/server/providers/openapi/test_deepobject_style.py rename to tests/server/plugins/openapi/test_deepobject_style.py index 1d934ec32..61f08750a 100644 --- a/tests/server/providers/openapi/test_deepobject_style.py +++ b/tests/server/plugins/openapi/test_deepobject_style.py @@ -5,7 +5,7 @@ import pytest from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.server.providers.openapi import OpenAPIProvider +from fastmcp.server.plugins.openapi.provider import OpenAPIProvider def create_openapi_server( diff --git a/tests/server/providers/openapi/test_end_to_end_compatibility.py b/tests/server/plugins/openapi/test_end_to_end_compatibility.py similarity index 99% rename from tests/server/providers/openapi/test_end_to_end_compatibility.py rename to tests/server/plugins/openapi/test_end_to_end_compatibility.py index 0680c0d2d..302628cd1 100644 --- a/tests/server/providers/openapi/test_end_to_end_compatibility.py +++ b/tests/server/plugins/openapi/test_end_to_end_compatibility.py @@ -5,7 +5,7 @@ import pytest from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.server.providers.openapi import OpenAPIProvider +from fastmcp.server.plugins.openapi.provider import OpenAPIProvider def create_openapi_server( diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/plugins/openapi/test_openapi_features.py similarity index 99% rename from tests/server/providers/openapi/test_openapi_features.py rename to tests/server/plugins/openapi/test_openapi_features.py index ac78f1b10..6a9829b91 100644 --- a/tests/server/providers/openapi/test_openapi_features.py +++ b/tests/server/plugins/openapi/test_openapi_features.py @@ -8,12 +8,12 @@ from httpx import Response from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.server.providers.openapi import OpenAPIProvider -from fastmcp.server.providers.openapi.components import ( +from fastmcp.server.plugins.openapi.components import ( _extract_mime_type_from_route, _redact_headers, ) -from fastmcp.server.providers.openapi.routing import MCPType, RouteMap +from fastmcp.server.plugins.openapi.provider import OpenAPIProvider +from fastmcp.server.plugins.openapi.routing import MCPType, RouteMap from fastmcp.utilities.openapi.models import HTTPRoute, ResponseInfo diff --git a/tests/server/providers/openapi/test_openapi_performance.py b/tests/server/plugins/openapi/test_openapi_performance.py similarity index 100% rename from tests/server/providers/openapi/test_openapi_performance.py rename to tests/server/plugins/openapi/test_openapi_performance.py diff --git a/tests/server/providers/openapi/test_parameter_collisions.py b/tests/server/plugins/openapi/test_parameter_collisions.py similarity index 99% rename from tests/server/providers/openapi/test_parameter_collisions.py rename to tests/server/plugins/openapi/test_parameter_collisions.py index 09a5a3c5d..9f0480c98 100644 --- a/tests/server/providers/openapi/test_parameter_collisions.py +++ b/tests/server/plugins/openapi/test_parameter_collisions.py @@ -5,7 +5,7 @@ import pytest from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.server.providers.openapi import OpenAPIProvider +from fastmcp.server.plugins.openapi.provider import OpenAPIProvider def create_openapi_server( diff --git a/tests/server/providers/openapi/test_performance_comparison.py b/tests/server/plugins/openapi/test_performance_comparison.py similarity index 99% rename from tests/server/providers/openapi/test_performance_comparison.py rename to tests/server/plugins/openapi/test_performance_comparison.py index 66f55bd1b..71f6b00e5 100644 --- a/tests/server/providers/openapi/test_performance_comparison.py +++ b/tests/server/plugins/openapi/test_performance_comparison.py @@ -7,7 +7,7 @@ import httpx import pytest from fastmcp import FastMCP -from fastmcp.server.providers.openapi import OpenAPIProvider +from fastmcp.server.plugins.openapi.provider import OpenAPIProvider def create_openapi_server( diff --git a/tests/server/providers/openapi/test_server.py b/tests/server/plugins/openapi/test_server.py similarity index 99% rename from tests/server/providers/openapi/test_server.py rename to tests/server/plugins/openapi/test_server.py index af3641ca0..bcd3e3151 100644 --- a/tests/server/providers/openapi/test_server.py +++ b/tests/server/plugins/openapi/test_server.py @@ -5,8 +5,7 @@ import pytest from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.server.providers.openapi import OpenAPIProvider -from fastmcp.server.providers.openapi.provider import DEFAULT_TIMEOUT +from fastmcp.server.plugins.openapi.provider import DEFAULT_TIMEOUT, OpenAPIProvider class TestOpenAPIProviderServerVariables: diff --git a/tests/server/plugins/test_openapi_plugin.py b/tests/server/plugins/test_openapi_plugin.py new file mode 100644 index 000000000..c07c36e3b --- /dev/null +++ b/tests/server/plugins/test_openapi_plugin.py @@ -0,0 +1,286 @@ +"""Tests for the OpenAPI plugin wrapper. + +Transform/provider behavior is covered by the existing OpenAPIProvider +tests in `tests/server/providers/openapi/`. This file only covers +plugin-layer concerns — config validation, dict→RouteMap conversion, +spec_path loading, and the escape-hatch wiring. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pytest +from pydantic import ValidationError + +from fastmcp import Client, FastMCP +from fastmcp.server.plugins.openapi import MCPType, OpenAPI, OpenAPIConfig, RouteMap +from fastmcp.server.plugins.openapi.plugin import RouteMapDict +from fastmcp.server.plugins.openapi.provider import OpenAPIProvider + +PETSTORE_SPEC: dict = { + "openapi": "3.0.0", + "info": {"title": "Petstore", "version": "1.0"}, + "servers": [{"url": "https://petstore.example.com"}], + "paths": { + "/pets": { + "get": { + "operationId": "list_pets", + "responses": {"200": {"description": "ok"}}, + }, + "post": { + "operationId": "create_pet", + "responses": {"201": {"description": "created"}}, + }, + }, + "/pets/{id}": { + "get": { + "operationId": "get_pet", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "ok"}}, + } + }, + }, +} + + +class TestOpenAPIConfig: + def test_config_generic_binding(self): + assert OpenAPI._config_cls is OpenAPIConfig + + def test_default_config_instantiable(self): + """Defaults must pass the plugin framework's instantiate-with-no-args + contract. The spec/spec_path check fires at providers() time, not + at Config construction.""" + assert OpenAPIConfig() # must not raise + + def test_unknown_config_key_rejected(self): + with pytest.raises((ValidationError, Exception), match="forbid|extra"): + OpenAPIConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + + def test_meta_name_is_single_word(self): + """'openapi' is one technical term — explicit meta override + prevents the kebab auto-deriver from producing 'open-api'.""" + assert OpenAPI.meta.name == "openapi" + assert OpenAPI.meta.version is None + + +class TestSpecLoading: + async def test_inline_spec_builds_provider(self): + plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC)) + mcp = FastMCP("petstore", plugins=[plugin]) + + async with Client(mcp) as c: + tools = await c.list_tools() + names = {t.name for t in tools} + + assert {"list_pets", "create_pet", "get_pet"}.issubset(names) + + async def test_spec_path_loads_from_disk(self, tmp_path: Path): + spec_file = tmp_path / "petstore.json" + spec_file.write_text(json.dumps(PETSTORE_SPEC)) + + plugin = OpenAPI(OpenAPIConfig(spec_path=str(spec_file))) + mcp = FastMCP("petstore", plugins=[plugin]) + + async with Client(mcp) as c: + tools = await c.list_tools() + names = {t.name for t in tools} + + assert {"list_pets", "create_pet", "get_pet"}.issubset(names) + + async def test_spec_path_loads_utf8_regardless_of_locale(self, tmp_path: Path): + """Spec files must load as UTF-8, not via the process locale. + Otherwise a spec with non-ASCII descriptions (German umlauts, + Japanese, fancy quotes, etc.) fails on non-UTF-8 systems like + Windows cp1252 — see PR #4015 review thread.""" + spec_with_unicode = { + **PETSTORE_SPEC, + "info": {"title": "Pëtstöre — 宠物商店", "version": "1.0"}, + } + spec_file = tmp_path / "petstore-unicode.json" + spec_file.write_text( + json.dumps(spec_with_unicode, ensure_ascii=False), + encoding="utf-8", + ) + + plugin = OpenAPI(OpenAPIConfig(spec_path=str(spec_file))) + providers = plugin.providers() + assert isinstance(providers[0], OpenAPIProvider) + + def test_missing_spec_fails_at_build_time(self): + plugin = OpenAPI(OpenAPIConfig()) + with pytest.raises(ValueError, match="spec.*spec_path"): + plugin.providers() + + def test_both_spec_and_spec_path_rejected(self, tmp_path: Path): + spec_file = tmp_path / "spec.json" + spec_file.write_text(json.dumps(PETSTORE_SPEC)) + + plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC, spec_path=str(spec_file))) + with pytest.raises(ValueError, match="exactly one"): + plugin.providers() + + +class TestRouteMapping: + def test_route_maps_dict_form_converts_to_typed(self): + plugin = OpenAPI( + OpenAPIConfig( + spec=PETSTORE_SPEC, + route_maps=[ + RouteMapDict( + mcp_type="RESOURCE", methods=["GET"], pattern=r"^/pets$" + ), + ], + ) + ) + providers = plugin.providers() + assert isinstance(providers[0], OpenAPIProvider) + # The GET /pets route should have become a resource, not a tool. + + async def test_list_pets_maps_to_resource_via_config(self): + plugin = OpenAPI( + OpenAPIConfig( + spec=PETSTORE_SPEC, + route_maps=[ + RouteMapDict( + mcp_type="RESOURCE", methods=["GET"], pattern=r"^/pets$" + ), + ], + ) + ) + mcp = FastMCP("petstore", plugins=[plugin]) + + async with Client(mcp) as c: + tools = {t.name for t in await c.list_tools()} + resources = {str(r.uri) for r in await c.list_resources()} + + assert "list_pets" not in tools + assert any("list_pets" in uri or "/pets" in uri for uri in resources) + + def test_typed_route_maps_override_dict_config(self): + """When users pass typed `route_maps=` to `__init__`, that beats + the dict form in Config — advanced users shouldn't be shadowed + by an empty default.""" + plugin = OpenAPI( + OpenAPIConfig(spec=PETSTORE_SPEC), + route_maps=[RouteMap(mcp_type=MCPType.EXCLUDE, pattern=r".*")], + ) + providers = plugin.providers() + provider = providers[0] + # Every route was excluded → provider has no tools/resources. + assert isinstance(provider, OpenAPIProvider) + + +class TestDefaultClient: + async def test_plugin_built_client_is_closed_on_provider_lifespan_exit(self): + """When the plugin builds its own httpx client (user didn't pass + `client=`), the provider's lifespan must still close it on + shutdown. A leaked client was bug noted on PR #4015.""" + plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC)) + provider = plugin.providers()[0] + assert isinstance(provider, OpenAPIProvider) + client = provider._client + + assert not client.is_closed + async with provider.lifespan(): + pass + assert client.is_closed + + async def test_server_variable_defaults_are_substituted(self): + """Spec servers with `{variable}` placeholders must be resolved + using `servers[0].variables[name].default` before going to the + httpx client — otherwise the literal template leaks into every + request URL.""" + templated_spec = { + **PETSTORE_SPEC, + "servers": [ + { + "url": "https://{region}.api.example.com", + "variables": {"region": {"default": "us-east"}}, + } + ], + } + plugin = OpenAPI(OpenAPIConfig(spec=templated_spec)) + provider = plugin.providers()[0] + assert isinstance(provider, OpenAPIProvider) + assert str(provider._client.base_url) == "https://us-east.api.example.com" + + +class TestEscapeHatches: + async def test_custom_client_is_used(self): + """Passing `client=` bypasses the auto-derived httpx client.""" + client = httpx.AsyncClient(base_url="https://override.example.com") + plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC), client=client) + providers = plugin.providers() + assert isinstance(providers[0], OpenAPIProvider) + # Access the provider's client through the known private attr. + # This is an implementation check — acceptable in a test. + assert providers[0]._client is client + await client.aclose() + + +class TestDeprecationShim: + """The old `fastmcp.server.providers.openapi` location now shims + back to the new plugin package. Top-level import is silent (so + unrelated code touching `fastmcp.server.providers` doesn't spray + warnings), but leaf submodules emit a `FastMCPDeprecationWarning`.""" + + async def test_top_level_old_path_is_silent_and_functional(self): + """Still-common `from fastmcp.server.providers.openapi import + OpenAPIProvider` keeps working without emitting a warning.""" + import warnings + + from fastmcp.exceptions import FastMCPDeprecationWarning + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + from fastmcp.server.providers.openapi import ( + OpenAPIProvider as LegacyProvider, + ) + + fastmcp_warns = [ + w for w in caught if issubclass(w.category, FastMCPDeprecationWarning) + ] + assert not fastmcp_warns + + client = httpx.AsyncClient(base_url="https://petstore.example.com") + provider = LegacyProvider(openapi_spec=PETSTORE_SPEC, client=client) + mcp = FastMCP("petstore", providers=[provider]) + + async with Client(mcp) as c: + tools = {t.name for t in await c.list_tools()} + + assert {"list_pets", "create_pet", "get_pet"}.issubset(tools) + assert LegacyProvider is OpenAPIProvider + await client.aclose() + + def test_leaf_submodule_import_emits_deprecation_warning(self): + import importlib + import sys + import warnings + + from fastmcp.exceptions import FastMCPDeprecationWarning + + sys.modules.pop("fastmcp.server.providers.openapi.provider", None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.import_module("fastmcp.server.providers.openapi.provider") + + fastmcp_warns = [ + w for w in caught if issubclass(w.category, FastMCPDeprecationWarning) + ] + assert any("plugins.openapi" in str(w.message) for w in fastmcp_warns), ( + f"expected FastMCPDeprecationWarning pointing at plugins.openapi, " + f"got {[(w.category.__name__, str(w.message)) for w in caught]}" + ) From 18ddf28b798cab2b00f57accc6d6e14acd0f217b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:43:53 -0400 Subject: [PATCH 13/17] Convert skills providers to the Skills plugin (#4017) --- .pre-commit-config.yaml | 7 +- src/fastmcp/server/plugins/skills/__init__.py | 15 + .../{providers => plugins}/skills/_common.py | 0 .../server/plugins/skills/claude_provider.py | 44 ++ .../plugins/skills/directory_provider.py | 153 ++++++ src/fastmcp/server/plugins/skills/plugin.py | 159 ++++++ .../server/plugins/skills/skill_provider.py | 449 +++++++++++++++++ .../server/plugins/skills/vendor_providers.py | 142 ++++++ .../server/providers/skills/__init__.py | 44 +- .../providers/skills/claude_provider.py | 53 +- .../providers/skills/directory_provider.py | 163 +------ .../server/providers/skills/skill_provider.py | 456 +----------------- .../providers/skills/vendor_providers.py | 170 ++----- tests/server/plugins/test_skills_plugin.py | 133 +++++ .../test_skills_provider.py | 15 +- .../test_skills_vendor_providers.py | 4 +- tests/utilities/test_skills.py | 2 +- 17 files changed, 1198 insertions(+), 811 deletions(-) create mode 100644 src/fastmcp/server/plugins/skills/__init__.py rename src/fastmcp/server/{providers => plugins}/skills/_common.py (100%) create mode 100644 src/fastmcp/server/plugins/skills/claude_provider.py create mode 100644 src/fastmcp/server/plugins/skills/directory_provider.py create mode 100644 src/fastmcp/server/plugins/skills/plugin.py create mode 100644 src/fastmcp/server/plugins/skills/skill_provider.py create mode 100644 src/fastmcp/server/plugins/skills/vendor_providers.py create mode 100644 tests/server/plugins/test_skills_plugin.py rename tests/server/{providers => plugins}/test_skills_provider.py (97%) rename tests/server/{providers => plugins}/test_skills_vendor_providers.py (98%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd3574432..3a193a5d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,8 +13,11 @@ repos: types_or: [yaml, json5] - repo: https://github.com/astral-sh/ruff-pre-commit - # Ruff version. - rev: v0.14.10 + # Ruff version. Keep in sync with the `ruff` pin in uv.lock so + # `uv run ruff format` locally and `prek run` / CI use the same + # ruleset — otherwise minor-version drift produces line-join and + # trailing-comma diffs that only show up in CI. + rev: v0.15.8 hooks: # Run the linter. - id: ruff-check diff --git a/src/fastmcp/server/plugins/skills/__init__.py b/src/fastmcp/server/plugins/skills/__init__.py new file mode 100644 index 000000000..518507a31 --- /dev/null +++ b/src/fastmcp/server/plugins/skills/__init__.py @@ -0,0 +1,15 @@ +"""Skills plugin — expose agent skill folders as MCP resources. + + from fastmcp import FastMCP + from fastmcp.server.plugins.skills import Skills, SkillsConfig + + mcp = FastMCP("skills", plugins=[Skills(SkillsConfig(vendor="claude"))]) + +The underlying `SkillProvider` and `SkillsDirectoryProvider` classes +live on `.skill_provider` and `.directory_provider` submodules for +direct-composition use cases; the plugin is the canonical entry point. +""" + +from fastmcp.server.plugins.skills.plugin import Skills, SkillsConfig + +__all__ = ["Skills", "SkillsConfig"] diff --git a/src/fastmcp/server/providers/skills/_common.py b/src/fastmcp/server/plugins/skills/_common.py similarity index 100% rename from src/fastmcp/server/providers/skills/_common.py rename to src/fastmcp/server/plugins/skills/_common.py diff --git a/src/fastmcp/server/plugins/skills/claude_provider.py b/src/fastmcp/server/plugins/skills/claude_provider.py new file mode 100644 index 000000000..f7a96caff --- /dev/null +++ b/src/fastmcp/server/plugins/skills/claude_provider.py @@ -0,0 +1,44 @@ +"""Claude-specific skills provider for Claude Code skills.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider + + +class ClaudeSkillsProvider(SkillsDirectoryProvider): + """Provider for Claude Code skills from ~/.claude/skills/. + + A convenience subclass that sets the default root to Claude's skills location. + + Args: + reload: If True, re-scan on every request. Defaults to False. + supporting_files: How supporting files are exposed: + - "template": Accessed via ResourceTemplate, hidden from list_resources(). + - "resources": Each file exposed as individual Resource in list_resources(). + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.skills import ClaudeSkillsProvider + + mcp = FastMCP("Claude Skills") + mcp.add_provider(ClaudeSkillsProvider()) # Uses default location + ``` + """ + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".claude" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) diff --git a/src/fastmcp/server/plugins/skills/directory_provider.py b/src/fastmcp/server/plugins/skills/directory_provider.py new file mode 100644 index 000000000..673d562b0 --- /dev/null +++ b/src/fastmcp/server/plugins/skills/directory_provider.py @@ -0,0 +1,153 @@ +"""Directory scanning provider for discovering multiple skills.""" + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Literal + +from fastmcp.resources.base import Resource +from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.plugins.skills.skill_provider import SkillProvider +from fastmcp.server.providers.aggregate import AggregateProvider +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.versions import VersionSpec + +logger = get_logger(__name__) + + +class SkillsDirectoryProvider(AggregateProvider): + """Provider that scans directories and creates a SkillProvider per skill folder. + + This extends AggregateProvider to combine multiple SkillProviders into one. + Each subdirectory containing a main file (default: SKILL.md) becomes a skill. + Can scan multiple root directories - if a skill name appears in multiple roots, + the first one found wins. + + Args: + roots: Root directory(ies) containing skill folders. Can be a single path + or a sequence of paths. + reload: If True, re-discover skills on each request. Defaults to False. + main_file_name: Name of the main skill file. Defaults to "SKILL.md". + supporting_files: How supporting files are exposed in child SkillProviders: + - "template": Accessed via ResourceTemplate, hidden from list_resources(). + - "resources": Each file exposed as individual Resource in list_resources(). + + Example: + ```python + from pathlib import Path + from fastmcp import FastMCP + from fastmcp.server.plugins.skills import SkillsDirectoryProvider + + mcp = FastMCP("Skills") + # Single directory + mcp.add_provider(SkillsDirectoryProvider( + roots=Path.home() / ".claude" / "skills", + reload=True, # Re-scan on each request + )) + # Multiple directories + mcp.add_provider(SkillsDirectoryProvider( + roots=[Path("/etc/skills"), Path.home() / ".local" / "skills"], + )) + ``` + """ + + def __init__( + self, + roots: str | Path | Sequence[str | Path], + reload: bool = False, + main_file_name: str = "SKILL.md", + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + super().__init__() + # Normalize to sequence: single path becomes list + if isinstance(roots, (str, Path)): + roots = [roots] + + self._roots = [Path(r).resolve() for r in roots] + self._reload = reload + self._main_file_name = main_file_name + self._supporting_files = supporting_files + self._discovered = False + + # Discover skills at init + self._discover_skills() + + def _discover_skills(self) -> None: + """Scan root directories and create SkillProvider per valid skill folder.""" + # Clear existing providers if reloading + self.providers.clear() + + seen_skill_names: set[str] = set() + + for root in self._roots: + if not root.exists(): + logger.debug(f"Skills root does not exist: {root}") + continue + + for skill_dir in root.iterdir(): + if not skill_dir.is_dir(): + continue + + main_file = skill_dir / self._main_file_name + if not main_file.exists(): + continue + + skill_name = skill_dir.name + # Skip if we've already seen this skill name (first wins) + if skill_name in seen_skill_names: + logger.debug( + f"Skipping duplicate skill '{skill_name}' from {root} " + f"(already found in earlier root)" + ) + continue + + try: + provider = SkillProvider( + skill_path=skill_dir, + main_file_name=self._main_file_name, + supporting_files=self._supporting_files, + ) + self.providers.append(provider) + seen_skill_names.add(skill_name) + except (FileNotFoundError, PermissionError, OSError): + logger.exception(f"Failed to load skill: {skill_dir.name}") + + self._discovered = True + logger.debug( + f"SkillsDirectoryProvider loaded {len(self.providers)} skills " + f"from {len(self._roots)} root(s)" + ) + + async def _ensure_discovered(self) -> None: + """Ensure skills are discovered, rediscovering if reload is enabled.""" + if self._reload or not self._discovered: + self._discover_skills() + + # Override list methods to support reload + async def _list_resources(self) -> Sequence[Resource]: + await self._ensure_discovered() + return await super()._list_resources() + + async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: + await self._ensure_discovered() + return await super()._list_resource_templates() + + async def _get_resource( + self, uri: str, version: VersionSpec | None = None + ) -> Resource | None: + await self._ensure_discovered() + return await super()._get_resource(uri, version) + + async def _get_resource_template( + self, uri: str, version: VersionSpec | None = None + ) -> ResourceTemplate | None: + await self._ensure_discovered() + return await super()._get_resource_template(uri, version) + + def __repr__(self) -> str: + roots_repr = self._roots[0] if len(self._roots) == 1 else self._roots + return ( + f"SkillsDirectoryProvider(roots={roots_repr!r}, " + f"reload={self._reload}, skills={len(self.providers)})" + ) diff --git a/src/fastmcp/server/plugins/skills/plugin.py b/src/fastmcp/server/plugins/skills/plugin.py new file mode 100644 index 000000000..3176d123a --- /dev/null +++ b/src/fastmcp/server/plugins/skills/plugin.py @@ -0,0 +1,159 @@ +"""Skills plugin: expose agent skill folders as MCP resources.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +from fastmcp.server.plugins.base import Plugin +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider +from fastmcp.server.plugins.skills.skill_provider import SkillProvider +from fastmcp.server.providers import Provider + +# Vendor-name → list of skill-root paths. Captures the same preset +# paths the vendor subclasses (`ClaudeSkillsProvider`, `CursorSkillsProvider`, +# etc.) used to hardcode. The dict lets `Skills(SkillsConfig(vendor="claude"))` +# replace seven separate subclass names with one plugin + an enum value. +VENDOR_PATHS: dict[str, list[Path]] = { + "claude": [Path.home() / ".claude" / "skills"], + "cursor": [Path.home() / ".cursor" / "skills"], + # VSCode and Copilot both resolve to ~/.copilot/skills in the pre-plugin + # vendor subclasses; preserved verbatim for backcompat. + "vscode": [Path.home() / ".copilot" / "skills"], + "copilot": [Path.home() / ".copilot" / "skills"], + "codex": [Path("/etc/codex/skills"), Path.home() / ".codex" / "skills"], + "gemini": [Path.home() / ".gemini" / "skills"], + "goose": [Path.home() / ".config" / "agents" / "skills"], + "opencode": [Path.home() / ".config" / "opencode" / "skills"], +} + +Vendor = Literal[ + "claude", + "copilot", + "codex", + "cursor", + "gemini", + "goose", + "opencode", + "vscode", +] + + +class SkillsConfig(BaseModel): + """Config model for the `Skills` plugin. + + Exactly one of `path`, `directory`, or `vendor` must be set. The + check fires when the plugin builds its provider, not at config + construction, so `SkillsConfig()` with no args still satisfies the + plugin-framework's defaults-are-instantiable contract. + """ + + model_config = ConfigDict(extra="forbid") + + path: str | None = None + """Path to a single skill folder. Equivalent to the old + `SkillProvider(path)` construction.""" + + directory: str | list[str] | None = None + """One or more directories to scan for skill subfolders. Equivalent + to `SkillsDirectoryProvider(roots=...)`.""" + + vendor: Vendor | None = None + """Preset for a known vendor tool — resolves to that tool's + conventional skills directory. Covers the set that the old + `ClaudeSkillsProvider`, `CursorSkillsProvider`, etc. subclasses + hardcoded.""" + + reload: bool = False + """Re-scan on each request. Useful in development; leave off in + production where the skill catalog doesn't change.""" + + main_file_name: str = "SKILL.md" + """Name of the main file inside a skill folder.""" + + supporting_files: Literal["template", "resources"] = "template" + """How non-main files inside a skill folder are exposed. + + - `"template"`: accessed via a single `ResourceTemplate`, hidden + from `list_resources()`. + - `"resources"`: each file becomes its own `Resource` in + `list_resources()`. + """ + + +class Skills(Plugin[SkillsConfig]): + """Mount agent skill folders as MCP resources. + + One plugin covers all three entry points the pre-plugin API + exposed as separate provider classes: single-folder, + scan-a-directory, and vendor-preset. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.skills import Skills, SkillsConfig + + # Vendor preset — the common case: + mcp = FastMCP( + "skills", + plugins=[Skills(SkillsConfig(vendor="claude"))], + ) + + # Custom directory: + mcp = FastMCP( + "skills", + plugins=[Skills(SkillsConfig(directory="./skills"))], + ) + + # Single skill folder: + mcp = FastMCP( + "skills", + plugins=[Skills(SkillsConfig(path="./skills/pdf-processing"))], + ) + ``` + """ + + def providers(self) -> list[Provider]: + return [self._build_provider()] + + def _build_provider(self) -> Provider: + sources_set = sum( + bool(x) + for x in (self.config.path, self.config.directory, self.config.vendor) + ) + if sources_set == 0: + raise ValueError( + "SkillsConfig requires one of `path`, `directory`, or `vendor`." + ) + if sources_set > 1: + raise ValueError( + "SkillsConfig requires exactly one of `path`, `directory`, or " + "`vendor` — got multiple." + ) + + if self.config.path is not None: + return SkillProvider( + skill_path=self.config.path, + main_file_name=self.config.main_file_name, + supporting_files=self.config.supporting_files, + ) + + if self.config.vendor is not None: + roots: Any = VENDOR_PATHS[self.config.vendor] + else: + # directory mode — accept str or list[str] + assert self.config.directory is not None + roots = ( + [self.config.directory] + if isinstance(self.config.directory, str) + else list(self.config.directory) + ) + + return SkillsDirectoryProvider( + roots=roots, + reload=self.config.reload, + main_file_name=self.config.main_file_name, + supporting_files=self.config.supporting_files, + ) diff --git a/src/fastmcp/server/plugins/skills/skill_provider.py b/src/fastmcp/server/plugins/skills/skill_provider.py new file mode 100644 index 000000000..c8eca8071 --- /dev/null +++ b/src/fastmcp/server/plugins/skills/skill_provider.py @@ -0,0 +1,449 @@ +"""Basic skill provider for handling a single skill folder.""" + +from __future__ import annotations + +import json +import mimetypes +from collections.abc import Sequence +from pathlib import Path +from typing import Any, Literal, cast + +from pydantic import AnyUrl + +from fastmcp.resources.base import Resource, ResourceResult +from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.plugins.skills._common import ( + SkillInfo, + parse_frontmatter, + scan_skill_files, +) +from fastmcp.server.providers.base import Provider +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.versions import VersionSpec + +logger = get_logger(__name__) + +# Ensure .md is recognized as text/markdown on all platforms (Windows may not have this) +mimetypes.add_type("text/markdown", ".md") + + +# ----------------------------------------------------------------------------- +# Skill-specific Resource and ResourceTemplate subclasses +# ----------------------------------------------------------------------------- + + +class SkillResource(Resource): + """A resource representing a skill's main file or manifest.""" + + skill_info: SkillInfo + is_manifest: bool = False + + def get_meta(self) -> dict[str, Any]: + meta = super().get_meta() + fastmcp = cast(dict[str, Any], meta["fastmcp"]) + fastmcp["skill"] = { + "name": self.skill_info.name, + "is_manifest": self.is_manifest, + } + return meta + + async def read(self) -> str | bytes | ResourceResult: + """Read the resource content.""" + if self.is_manifest: + return self._generate_manifest() + else: + main_file_path = self.skill_info.path / self.skill_info.main_file + return main_file_path.read_text() + + def _generate_manifest(self) -> str: + """Generate JSON manifest for the skill.""" + manifest = { + "skill": self.skill_info.name, + "files": [ + {"path": f.path, "size": f.size, "hash": f.hash} + for f in self.skill_info.files + ], + } + return json.dumps(manifest, indent=2) + + +class SkillFileTemplate(ResourceTemplate): + """A template for accessing files within a skill.""" + + skill_info: SkillInfo + + async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: + """Read a file from the skill directory.""" + file_path = arguments.get("path", "") + full_path = self.skill_info.path / file_path + + # Security: ensure path doesn't escape skill directory + try: + full_path = full_path.resolve() + if not full_path.is_relative_to(self.skill_info.path): + raise ValueError(f"Path {file_path} escapes skill directory") + except ValueError as e: + raise ValueError(f"Invalid path: {e}") from e + + if not full_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + if not full_path.is_file(): + raise ValueError(f"Not a file: {file_path}") + + # Determine if binary or text based on mime type + mime_type, _ = mimetypes.guess_type(str(full_path)) + if mime_type and mime_type.startswith("text/"): + return full_path.read_text() + else: + return full_path.read_bytes() + + async def _read( # type: ignore[override] + self, + uri: str, + params: dict[str, Any], + task_meta: Any = None, + ) -> ResourceResult: # ty:ignore[invalid-method-override] + """Server entry point - read file directly without creating ephemeral resource. + + Note: task_meta is ignored - this template doesn't support background tasks. + """ + # Call read() directly and convert to ResourceResult + result = await self.read(arguments=params) + return self.convert_result(result) + + async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: + """Create a resource for the given URI and parameters. + + Note: This is not typically used since _read() handles file reading directly. + Provided for compatibility with the ResourceTemplate interface. + """ + file_path = params.get("path", "") + full_path = (self.skill_info.path / file_path).resolve() + + # Security: ensure path doesn't escape skill directory + if not full_path.is_relative_to(self.skill_info.path): + raise ValueError(f"Path {file_path} escapes skill directory") + + mime_type, _ = mimetypes.guess_type(str(full_path)) + + # Create a SkillFileResource that can read the file + return SkillFileResource( + uri=AnyUrl(uri), + name=f"{self.skill_info.name}/{file_path}", + description=f"File from {self.skill_info.name} skill", + mime_type=mime_type or "application/octet-stream", + skill_info=self.skill_info, + file_path=file_path, + ) + + +class SkillFileResource(Resource): + """A resource representing a specific file within a skill.""" + + skill_info: SkillInfo + file_path: str + + def get_meta(self) -> dict[str, Any]: + meta = super().get_meta() + fastmcp = cast(dict[str, Any], meta["fastmcp"]) + fastmcp["skill"] = { + "name": self.skill_info.name, + } + return meta + + async def read(self) -> str | bytes | ResourceResult: + """Read the file content.""" + full_path = self.skill_info.path / self.file_path + + # Security check + full_path = full_path.resolve() + if not full_path.is_relative_to(self.skill_info.path): + raise ValueError(f"Path {self.file_path} escapes skill directory") + + if not full_path.exists(): + raise FileNotFoundError(f"File not found: {self.file_path}") + + mime_type, _ = mimetypes.guess_type(str(full_path)) + if mime_type and mime_type.startswith("text/"): + return full_path.read_text() + else: + return full_path.read_bytes() + + +# ----------------------------------------------------------------------------- +# SkillProvider - handles a SINGLE skill folder +# ----------------------------------------------------------------------------- + + +class SkillProvider(Provider): + """Provider that exposes a single skill folder as MCP resources. + + Each skill folder must contain a main file (default: SKILL.md) and may + contain additional supporting files. + + Exposes: + - A Resource for the main file (skill://{name}/SKILL.md) + - A Resource for the synthetic manifest (skill://{name}/_manifest) + - Supporting files via ResourceTemplate or Resources (configurable) + + Args: + skill_path: Path to the skill directory. + main_file_name: Name of the main skill file. Defaults to "SKILL.md". + supporting_files: How supporting files (everything except main file and + manifest) are exposed to clients: + - "template": Accessed via ResourceTemplate, hidden from list_resources(). + Clients discover files by reading the manifest first. + - "resources": Each file exposed as individual Resource in list_resources(). + Full enumeration upfront. + + Example: + ```python + from pathlib import Path + from fastmcp import FastMCP + from fastmcp.server.plugins.skills import SkillProvider + + mcp = FastMCP("My Skill") + mcp.add_provider(SkillProvider( + Path.home() / ".claude/skills/pdf-processing" + )) + ``` + """ + + def __init__( + self, + skill_path: str | Path, + main_file_name: str = "SKILL.md", + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + super().__init__() + self._skill_path = Path(skill_path).resolve() + self._main_file_name = main_file_name + self._supporting_files = supporting_files + self._skill_info: SkillInfo | None = None + + # Load at init to catch errors early + self._load_skill() + + def _load_skill(self) -> None: + """Load and parse the skill directory.""" + main_file = self._skill_path / self._main_file_name + + if not self._skill_path.exists(): + raise FileNotFoundError(f"Skill directory not found: {self._skill_path}") + + if not main_file.exists(): + raise FileNotFoundError( + f"Main skill file not found: {main_file}. " + f"Expected {self._main_file_name} in {self._skill_path}" + ) + + content = main_file.read_text() + frontmatter, body = parse_frontmatter(content) + + # Get description from frontmatter or first non-empty line + description = frontmatter.get("description", "") + if not description: + for line in body.strip().split("\n"): + line = line.strip() + if line and not line.startswith("#"): + description = line[:200] + break + elif line.startswith("#"): + description = line.lstrip("#").strip()[:200] + break + + # Scan all files in the skill directory + files = scan_skill_files(self._skill_path) + + self._skill_info = SkillInfo( + name=self._skill_path.name, + description=description or f"Skill: {self._skill_path.name}", + path=self._skill_path, + main_file=self._main_file_name, + files=files, + frontmatter=frontmatter, + ) + + logger.debug(f"SkillProvider loaded skill: {self._skill_info.name}") + + @property + def skill_info(self) -> SkillInfo: + """Get the loaded skill info.""" + if self._skill_info is None: + raise RuntimeError("Skill not loaded") + return self._skill_info + + # ------------------------------------------------------------------------- + # Provider interface implementation + # ------------------------------------------------------------------------- + + async def _list_resources(self) -> Sequence[Resource]: + """List skill resources.""" + skill = self.skill_info + resources: list[Resource] = [] + + # Main skill file + resources.append( + SkillResource( + uri=AnyUrl(f"skill://{skill.name}/{self._main_file_name}"), + name=f"{skill.name}/{self._main_file_name}", + description=skill.description, + mime_type="text/markdown", + skill_info=skill, + is_manifest=False, + ) + ) + + # Synthetic manifest + resources.append( + SkillResource( + uri=AnyUrl(f"skill://{skill.name}/_manifest"), + name=f"{skill.name}/_manifest", + description=f"File listing for {skill.name}", + mime_type="application/json", + skill_info=skill, + is_manifest=True, + ) + ) + + # If supporting_files="resources", add all supporting files as resources + if self._supporting_files == "resources": + for file_info in skill.files: + # Skip main file and manifest (already added) + if file_info.path == self._main_file_name: + continue + + mime_type, _ = mimetypes.guess_type(file_info.path) + resources.append( + SkillFileResource( + uri=AnyUrl(f"skill://{skill.name}/{file_info.path}"), + name=f"{skill.name}/{file_info.path}", + description=f"File from {skill.name} skill", + mime_type=mime_type or "application/octet-stream", + skill_info=skill, + file_path=file_info.path, + ) + ) + + return resources + + async def _get_resource( + self, uri: str, version: VersionSpec | None = None + ) -> Resource | None: + """Get a resource by URI.""" + skill = self.skill_info + + # Parse URI: skill://{skill_name}/{file_path} + if not uri.startswith("skill://"): + return None + + path_part = uri[len("skill://") :] + parts = path_part.split("/", 1) + if len(parts) != 2: + return None + + skill_name, file_path = parts + if skill_name != skill.name: + return None + + if file_path == "_manifest": + return SkillResource( + uri=AnyUrl(uri), + name=f"{skill_name}/_manifest", + description=f"File listing for {skill_name}", + mime_type="application/json", + skill_info=skill, + is_manifest=True, + ) + elif file_path == self._main_file_name: + return SkillResource( + uri=AnyUrl(uri), + name=f"{skill_name}/{self._main_file_name}", + description=skill.description, + mime_type="text/markdown", + skill_info=skill, + is_manifest=False, + ) + elif self._supporting_files == "resources": + # Check if it's a known supporting file + for file_info in skill.files: + if file_info.path == file_path: + mime_type, _ = mimetypes.guess_type(file_path) + return SkillFileResource( + uri=AnyUrl(uri), + name=f"{skill_name}/{file_path}", + description=f"File from {skill_name} skill", + mime_type=mime_type or "application/octet-stream", + skill_info=skill, + file_path=file_path, + ) + + return None + + async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: + """List resource templates for accessing files within the skill.""" + # Only expose template if supporting_files="template" + if self._supporting_files != "template": + return [] + + skill = self.skill_info + return [ + SkillFileTemplate( + uri_template=f"skill://{skill.name}/{{path*}}", + name=f"{skill.name}_files", + description=f"Access files within {skill.name}", + mime_type="application/octet-stream", + parameters={ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + skill_info=skill, + ) + ] + + async def _get_resource_template( + self, uri: str, version: VersionSpec | None = None + ) -> ResourceTemplate | None: + """Get a resource template that matches the given URI.""" + # Only match if supporting_files="template" + if self._supporting_files != "template": + return None + + skill = self.skill_info + + if not uri.startswith("skill://"): + return None + + path_part = uri[len("skill://") :] + parts = path_part.split("/", 1) + if len(parts) != 2: + return None + + skill_name, file_path = parts + if skill_name != skill.name: + return None + + # Don't match known resources (main file, manifest) + if file_path == "_manifest" or file_path == self._main_file_name: + return None + + return SkillFileTemplate( + uri_template=f"skill://{skill.name}/{{path*}}", + name=f"{skill.name}_files", + description=f"Access files within {skill.name}", + mime_type="application/octet-stream", + parameters={ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + skill_info=skill, + ) + + def __repr__(self) -> str: + return ( + f"SkillProvider(skill_path={self._skill_path!r}, " + f"supporting_files={self._supporting_files!r})" + ) diff --git a/src/fastmcp/server/plugins/skills/vendor_providers.py b/src/fastmcp/server/plugins/skills/vendor_providers.py new file mode 100644 index 000000000..b11e72839 --- /dev/null +++ b/src/fastmcp/server/plugins/skills/vendor_providers.py @@ -0,0 +1,142 @@ +"""Vendor-specific skills providers for various AI coding platforms.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider + + +class CursorSkillsProvider(SkillsDirectoryProvider): + """Cursor skills from ~/.cursor/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".cursor" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class VSCodeSkillsProvider(SkillsDirectoryProvider): + """VS Code skills from ~/.copilot/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".copilot" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class CodexSkillsProvider(SkillsDirectoryProvider): + """Codex skills from /etc/codex/skills/ and ~/.codex/skills/. + + Scans both system-level and user-level directories. System skills take + precedence if duplicates exist. + """ + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + system_root = Path("/etc/codex/skills") + user_root = Path.home() / ".codex" / "skills" + + # Include both paths (system first, then user) + roots = [system_root, user_root] + + super().__init__( + roots=roots, + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class GeminiSkillsProvider(SkillsDirectoryProvider): + """Gemini skills from ~/.gemini/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".gemini" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class GooseSkillsProvider(SkillsDirectoryProvider): + """Goose skills from ~/.config/agents/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".config" / "agents" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class CopilotSkillsProvider(SkillsDirectoryProvider): + """GitHub Copilot skills from ~/.copilot/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".copilot" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class OpenCodeSkillsProvider(SkillsDirectoryProvider): + """OpenCode skills from ~/.config/opencode/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".config" / "opencode" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) diff --git a/src/fastmcp/server/providers/skills/__init__.py b/src/fastmcp/server/providers/skills/__init__.py index b15c1c636..9c1f31408 100644 --- a/src/fastmcp/server/providers/skills/__init__.py +++ b/src/fastmcp/server/providers/skills/__init__.py @@ -1,35 +1,23 @@ -"""Skills providers for exposing agent skills as MCP resources. +"""Backwards-compatibility shim — skills providers moved to `fastmcp.server.plugins.skills`. -This module provides a two-layer architecture for skill discovery: +The preferred entry point is now the `Skills` plugin: -- **SkillProvider**: Handles a single skill folder, exposing its files as resources. -- **SkillsDirectoryProvider**: Scans a directory, creates a SkillProvider per folder. -- **Vendor providers**: Platform-specific providers for Claude, Cursor, VS Code, Codex, - Gemini, Goose, Copilot, and OpenCode. - -Example: - ```python - from pathlib import Path from fastmcp import FastMCP - from fastmcp.server.providers.skills import ClaudeSkillsProvider, SkillProvider + from fastmcp.server.plugins.skills import Skills, SkillsConfig - mcp = FastMCP("Skills Server") + mcp = FastMCP("skills", plugins=[Skills(SkillsConfig(vendor="claude"))]) - # Load a single skill - mcp.add_provider(SkillProvider(Path.home() / ".claude/skills/pdf-processing")) - - # Or load all skills in a directory - mcp.add_provider(ClaudeSkillsProvider()) # Uses ~/.claude/skills/ - ``` +The underlying `SkillProvider`, `SkillsDirectoryProvider`, and the +vendor subclasses (`ClaudeSkillsProvider`, `CursorSkillsProvider`, etc.) +remain importable from this package for direct composition. The +top-level import path is silent; importing from the leaf submodules +emits a `FastMCPDeprecationWarning`. """ -from __future__ import annotations - -# Import providers -from fastmcp.server.providers.skills.claude_provider import ClaudeSkillsProvider -from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider -from fastmcp.server.providers.skills.skill_provider import SkillProvider -from fastmcp.server.providers.skills.vendor_providers import ( +from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider +from fastmcp.server.plugins.skills.skill_provider import SkillProvider +from fastmcp.server.plugins.skills.vendor_providers import ( CodexSkillsProvider, CopilotSkillsProvider, CursorSkillsProvider, @@ -39,11 +27,9 @@ from fastmcp.server.providers.skills.vendor_providers import ( VSCodeSkillsProvider, ) - -# Backwards compatibility alias +# Backwards-compatibility alias preserved from the original module. SkillsProvider = SkillsDirectoryProvider - __all__ = [ "ClaudeSkillsProvider", "CodexSkillsProvider", @@ -54,6 +40,6 @@ __all__ = [ "OpenCodeSkillsProvider", "SkillProvider", "SkillsDirectoryProvider", - "SkillsProvider", # Backwards compatibility alias + "SkillsProvider", "VSCodeSkillsProvider", ] diff --git a/src/fastmcp/server/providers/skills/claude_provider.py b/src/fastmcp/server/providers/skills/claude_provider.py index b7264953c..75ab8d8ad 100644 --- a/src/fastmcp/server/providers/skills/claude_provider.py +++ b/src/fastmcp/server/providers/skills/claude_provider.py @@ -1,44 +1,17 @@ -"""Claude-specific skills provider for Claude Code skills.""" +"""Deprecation shim — `ClaudeSkillsProvider` moved to `fastmcp.server.plugins.skills.claude_provider`.""" -from __future__ import annotations +import warnings -from pathlib import Path -from typing import Literal +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider -from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider +warnings.warn( + "fastmcp.server.providers.skills.claude_provider has moved to " + "fastmcp.server.plugins.skills.claude_provider. Prefer the Skills " + 'plugin: `Skills(SkillsConfig(vendor="claude"))`. This old ' + "leaf-submodule import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, +) - -class ClaudeSkillsProvider(SkillsDirectoryProvider): - """Provider for Claude Code skills from ~/.claude/skills/. - - A convenience subclass that sets the default root to Claude's skills location. - - Args: - reload: If True, re-scan on every request. Defaults to False. - supporting_files: How supporting files are exposed: - - "template": Accessed via ResourceTemplate, hidden from list_resources(). - - "resources": Each file exposed as individual Resource in list_resources(). - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers.skills import ClaudeSkillsProvider - - mcp = FastMCP("Claude Skills") - mcp.add_provider(ClaudeSkillsProvider()) # Uses default location - ``` - """ - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".claude" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) +__all__ = ["ClaudeSkillsProvider"] diff --git a/src/fastmcp/server/providers/skills/directory_provider.py b/src/fastmcp/server/providers/skills/directory_provider.py index c390b42f5..80b807927 100644 --- a/src/fastmcp/server/providers/skills/directory_provider.py +++ b/src/fastmcp/server/providers/skills/directory_provider.py @@ -1,153 +1,18 @@ -"""Directory scanning provider for discovering multiple skills.""" +"""Deprecation shim — `SkillsDirectoryProvider` moved to `fastmcp.server.plugins.skills.directory_provider`.""" -from __future__ import annotations +import warnings -from collections.abc import Sequence -from pathlib import Path -from typing import Literal +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider -from fastmcp.resources.base import Resource -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.providers.aggregate import AggregateProvider -from fastmcp.server.providers.skills.skill_provider import SkillProvider -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.versions import VersionSpec +warnings.warn( + "fastmcp.server.providers.skills.directory_provider has moved to " + "fastmcp.server.plugins.skills.directory_provider. Prefer the " + "Skills plugin: `from fastmcp.server.plugins.skills import Skills`. " + "This old leaf-submodule import path will be removed in a future " + "release.", + FastMCPDeprecationWarning, + stacklevel=2, +) -logger = get_logger(__name__) - - -class SkillsDirectoryProvider(AggregateProvider): - """Provider that scans directories and creates a SkillProvider per skill folder. - - This extends AggregateProvider to combine multiple SkillProviders into one. - Each subdirectory containing a main file (default: SKILL.md) becomes a skill. - Can scan multiple root directories - if a skill name appears in multiple roots, - the first one found wins. - - Args: - roots: Root directory(ies) containing skill folders. Can be a single path - or a sequence of paths. - reload: If True, re-discover skills on each request. Defaults to False. - main_file_name: Name of the main skill file. Defaults to "SKILL.md". - supporting_files: How supporting files are exposed in child SkillProviders: - - "template": Accessed via ResourceTemplate, hidden from list_resources(). - - "resources": Each file exposed as individual Resource in list_resources(). - - Example: - ```python - from pathlib import Path - from fastmcp import FastMCP - from fastmcp.server.providers.skills import SkillsDirectoryProvider - - mcp = FastMCP("Skills") - # Single directory - mcp.add_provider(SkillsDirectoryProvider( - roots=Path.home() / ".claude" / "skills", - reload=True, # Re-scan on each request - )) - # Multiple directories - mcp.add_provider(SkillsDirectoryProvider( - roots=[Path("/etc/skills"), Path.home() / ".local" / "skills"], - )) - ``` - """ - - def __init__( - self, - roots: str | Path | Sequence[str | Path], - reload: bool = False, - main_file_name: str = "SKILL.md", - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - super().__init__() - # Normalize to sequence: single path becomes list - if isinstance(roots, (str, Path)): - roots = [roots] - - self._roots = [Path(r).resolve() for r in roots] - self._reload = reload - self._main_file_name = main_file_name - self._supporting_files = supporting_files - self._discovered = False - - # Discover skills at init - self._discover_skills() - - def _discover_skills(self) -> None: - """Scan root directories and create SkillProvider per valid skill folder.""" - # Clear existing providers if reloading - self.providers.clear() - - seen_skill_names: set[str] = set() - - for root in self._roots: - if not root.exists(): - logger.debug(f"Skills root does not exist: {root}") - continue - - for skill_dir in root.iterdir(): - if not skill_dir.is_dir(): - continue - - main_file = skill_dir / self._main_file_name - if not main_file.exists(): - continue - - skill_name = skill_dir.name - # Skip if we've already seen this skill name (first wins) - if skill_name in seen_skill_names: - logger.debug( - f"Skipping duplicate skill '{skill_name}' from {root} " - f"(already found in earlier root)" - ) - continue - - try: - provider = SkillProvider( - skill_path=skill_dir, - main_file_name=self._main_file_name, - supporting_files=self._supporting_files, - ) - self.providers.append(provider) - seen_skill_names.add(skill_name) - except (FileNotFoundError, PermissionError, OSError): - logger.exception(f"Failed to load skill: {skill_dir.name}") - - self._discovered = True - logger.debug( - f"SkillsDirectoryProvider loaded {len(self.providers)} skills " - f"from {len(self._roots)} root(s)" - ) - - async def _ensure_discovered(self) -> None: - """Ensure skills are discovered, rediscovering if reload is enabled.""" - if self._reload or not self._discovered: - self._discover_skills() - - # Override list methods to support reload - async def _list_resources(self) -> Sequence[Resource]: - await self._ensure_discovered() - return await super()._list_resources() - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - await self._ensure_discovered() - return await super()._list_resource_templates() - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - await self._ensure_discovered() - return await super()._get_resource(uri, version) - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - await self._ensure_discovered() - return await super()._get_resource_template(uri, version) - - def __repr__(self) -> str: - roots_repr = self._roots[0] if len(self._roots) == 1 else self._roots - return ( - f"SkillsDirectoryProvider(roots={roots_repr!r}, " - f"reload={self._reload}, skills={len(self.providers)})" - ) +__all__ = ["SkillsDirectoryProvider"] diff --git a/src/fastmcp/server/providers/skills/skill_provider.py b/src/fastmcp/server/providers/skills/skill_provider.py index 8e8d2cf4b..27eecdb18 100644 --- a/src/fastmcp/server/providers/skills/skill_provider.py +++ b/src/fastmcp/server/providers/skills/skill_provider.py @@ -1,449 +1,17 @@ -"""Basic skill provider for handling a single skill folder.""" +"""Deprecation shim — `SkillProvider` moved to `fastmcp.server.plugins.skills.skill_provider`.""" -from __future__ import annotations +import warnings -import json -import mimetypes -from collections.abc import Sequence -from pathlib import Path -from typing import Any, Literal, cast +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.skills.skill_provider import SkillProvider -from pydantic import AnyUrl - -from fastmcp.resources.base import Resource, ResourceResult -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.skills._common import ( - SkillInfo, - parse_frontmatter, - scan_skill_files, +warnings.warn( + "fastmcp.server.providers.skills.skill_provider has moved to " + "fastmcp.server.plugins.skills.skill_provider. Prefer the Skills " + "plugin: `from fastmcp.server.plugins.skills import Skills`. This " + "old leaf-submodule import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, ) -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.versions import VersionSpec -logger = get_logger(__name__) - -# Ensure .md is recognized as text/markdown on all platforms (Windows may not have this) -mimetypes.add_type("text/markdown", ".md") - - -# ----------------------------------------------------------------------------- -# Skill-specific Resource and ResourceTemplate subclasses -# ----------------------------------------------------------------------------- - - -class SkillResource(Resource): - """A resource representing a skill's main file or manifest.""" - - skill_info: SkillInfo - is_manifest: bool = False - - def get_meta(self) -> dict[str, Any]: - meta = super().get_meta() - fastmcp = cast(dict[str, Any], meta["fastmcp"]) - fastmcp["skill"] = { - "name": self.skill_info.name, - "is_manifest": self.is_manifest, - } - return meta - - async def read(self) -> str | bytes | ResourceResult: - """Read the resource content.""" - if self.is_manifest: - return self._generate_manifest() - else: - main_file_path = self.skill_info.path / self.skill_info.main_file - return main_file_path.read_text() - - def _generate_manifest(self) -> str: - """Generate JSON manifest for the skill.""" - manifest = { - "skill": self.skill_info.name, - "files": [ - {"path": f.path, "size": f.size, "hash": f.hash} - for f in self.skill_info.files - ], - } - return json.dumps(manifest, indent=2) - - -class SkillFileTemplate(ResourceTemplate): - """A template for accessing files within a skill.""" - - skill_info: SkillInfo - - async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: - """Read a file from the skill directory.""" - file_path = arguments.get("path", "") - full_path = self.skill_info.path / file_path - - # Security: ensure path doesn't escape skill directory - try: - full_path = full_path.resolve() - if not full_path.is_relative_to(self.skill_info.path): - raise ValueError(f"Path {file_path} escapes skill directory") - except ValueError as e: - raise ValueError(f"Invalid path: {e}") from e - - if not full_path.exists(): - raise FileNotFoundError(f"File not found: {file_path}") - - if not full_path.is_file(): - raise ValueError(f"Not a file: {file_path}") - - # Determine if binary or text based on mime type - mime_type, _ = mimetypes.guess_type(str(full_path)) - if mime_type and mime_type.startswith("text/"): - return full_path.read_text() - else: - return full_path.read_bytes() - - async def _read( # type: ignore[override] - self, - uri: str, - params: dict[str, Any], - task_meta: Any = None, - ) -> ResourceResult: # ty:ignore[invalid-method-override] - """Server entry point - read file directly without creating ephemeral resource. - - Note: task_meta is ignored - this template doesn't support background tasks. - """ - # Call read() directly and convert to ResourceResult - result = await self.read(arguments=params) - return self.convert_result(result) - - async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: - """Create a resource for the given URI and parameters. - - Note: This is not typically used since _read() handles file reading directly. - Provided for compatibility with the ResourceTemplate interface. - """ - file_path = params.get("path", "") - full_path = (self.skill_info.path / file_path).resolve() - - # Security: ensure path doesn't escape skill directory - if not full_path.is_relative_to(self.skill_info.path): - raise ValueError(f"Path {file_path} escapes skill directory") - - mime_type, _ = mimetypes.guess_type(str(full_path)) - - # Create a SkillFileResource that can read the file - return SkillFileResource( - uri=AnyUrl(uri), - name=f"{self.skill_info.name}/{file_path}", - description=f"File from {self.skill_info.name} skill", - mime_type=mime_type or "application/octet-stream", - skill_info=self.skill_info, - file_path=file_path, - ) - - -class SkillFileResource(Resource): - """A resource representing a specific file within a skill.""" - - skill_info: SkillInfo - file_path: str - - def get_meta(self) -> dict[str, Any]: - meta = super().get_meta() - fastmcp = cast(dict[str, Any], meta["fastmcp"]) - fastmcp["skill"] = { - "name": self.skill_info.name, - } - return meta - - async def read(self) -> str | bytes | ResourceResult: - """Read the file content.""" - full_path = self.skill_info.path / self.file_path - - # Security check - full_path = full_path.resolve() - if not full_path.is_relative_to(self.skill_info.path): - raise ValueError(f"Path {self.file_path} escapes skill directory") - - if not full_path.exists(): - raise FileNotFoundError(f"File not found: {self.file_path}") - - mime_type, _ = mimetypes.guess_type(str(full_path)) - if mime_type and mime_type.startswith("text/"): - return full_path.read_text() - else: - return full_path.read_bytes() - - -# ----------------------------------------------------------------------------- -# SkillProvider - handles a SINGLE skill folder -# ----------------------------------------------------------------------------- - - -class SkillProvider(Provider): - """Provider that exposes a single skill folder as MCP resources. - - Each skill folder must contain a main file (default: SKILL.md) and may - contain additional supporting files. - - Exposes: - - A Resource for the main file (skill://{name}/SKILL.md) - - A Resource for the synthetic manifest (skill://{name}/_manifest) - - Supporting files via ResourceTemplate or Resources (configurable) - - Args: - skill_path: Path to the skill directory. - main_file_name: Name of the main skill file. Defaults to "SKILL.md". - supporting_files: How supporting files (everything except main file and - manifest) are exposed to clients: - - "template": Accessed via ResourceTemplate, hidden from list_resources(). - Clients discover files by reading the manifest first. - - "resources": Each file exposed as individual Resource in list_resources(). - Full enumeration upfront. - - Example: - ```python - from pathlib import Path - from fastmcp import FastMCP - from fastmcp.server.providers.skills import SkillProvider - - mcp = FastMCP("My Skill") - mcp.add_provider(SkillProvider( - Path.home() / ".claude/skills/pdf-processing" - )) - ``` - """ - - def __init__( - self, - skill_path: str | Path, - main_file_name: str = "SKILL.md", - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - super().__init__() - self._skill_path = Path(skill_path).resolve() - self._main_file_name = main_file_name - self._supporting_files = supporting_files - self._skill_info: SkillInfo | None = None - - # Load at init to catch errors early - self._load_skill() - - def _load_skill(self) -> None: - """Load and parse the skill directory.""" - main_file = self._skill_path / self._main_file_name - - if not self._skill_path.exists(): - raise FileNotFoundError(f"Skill directory not found: {self._skill_path}") - - if not main_file.exists(): - raise FileNotFoundError( - f"Main skill file not found: {main_file}. " - f"Expected {self._main_file_name} in {self._skill_path}" - ) - - content = main_file.read_text() - frontmatter, body = parse_frontmatter(content) - - # Get description from frontmatter or first non-empty line - description = frontmatter.get("description", "") - if not description: - for line in body.strip().split("\n"): - line = line.strip() - if line and not line.startswith("#"): - description = line[:200] - break - elif line.startswith("#"): - description = line.lstrip("#").strip()[:200] - break - - # Scan all files in the skill directory - files = scan_skill_files(self._skill_path) - - self._skill_info = SkillInfo( - name=self._skill_path.name, - description=description or f"Skill: {self._skill_path.name}", - path=self._skill_path, - main_file=self._main_file_name, - files=files, - frontmatter=frontmatter, - ) - - logger.debug(f"SkillProvider loaded skill: {self._skill_info.name}") - - @property - def skill_info(self) -> SkillInfo: - """Get the loaded skill info.""" - if self._skill_info is None: - raise RuntimeError("Skill not loaded") - return self._skill_info - - # ------------------------------------------------------------------------- - # Provider interface implementation - # ------------------------------------------------------------------------- - - async def _list_resources(self) -> Sequence[Resource]: - """List skill resources.""" - skill = self.skill_info - resources: list[Resource] = [] - - # Main skill file - resources.append( - SkillResource( - uri=AnyUrl(f"skill://{skill.name}/{self._main_file_name}"), - name=f"{skill.name}/{self._main_file_name}", - description=skill.description, - mime_type="text/markdown", - skill_info=skill, - is_manifest=False, - ) - ) - - # Synthetic manifest - resources.append( - SkillResource( - uri=AnyUrl(f"skill://{skill.name}/_manifest"), - name=f"{skill.name}/_manifest", - description=f"File listing for {skill.name}", - mime_type="application/json", - skill_info=skill, - is_manifest=True, - ) - ) - - # If supporting_files="resources", add all supporting files as resources - if self._supporting_files == "resources": - for file_info in skill.files: - # Skip main file and manifest (already added) - if file_info.path == self._main_file_name: - continue - - mime_type, _ = mimetypes.guess_type(file_info.path) - resources.append( - SkillFileResource( - uri=AnyUrl(f"skill://{skill.name}/{file_info.path}"), - name=f"{skill.name}/{file_info.path}", - description=f"File from {skill.name} skill", - mime_type=mime_type or "application/octet-stream", - skill_info=skill, - file_path=file_info.path, - ) - ) - - return resources - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get a resource by URI.""" - skill = self.skill_info - - # Parse URI: skill://{skill_name}/{file_path} - if not uri.startswith("skill://"): - return None - - path_part = uri[len("skill://") :] - parts = path_part.split("/", 1) - if len(parts) != 2: - return None - - skill_name, file_path = parts - if skill_name != skill.name: - return None - - if file_path == "_manifest": - return SkillResource( - uri=AnyUrl(uri), - name=f"{skill_name}/_manifest", - description=f"File listing for {skill_name}", - mime_type="application/json", - skill_info=skill, - is_manifest=True, - ) - elif file_path == self._main_file_name: - return SkillResource( - uri=AnyUrl(uri), - name=f"{skill_name}/{self._main_file_name}", - description=skill.description, - mime_type="text/markdown", - skill_info=skill, - is_manifest=False, - ) - elif self._supporting_files == "resources": - # Check if it's a known supporting file - for file_info in skill.files: - if file_info.path == file_path: - mime_type, _ = mimetypes.guess_type(file_path) - return SkillFileResource( - uri=AnyUrl(uri), - name=f"{skill_name}/{file_path}", - description=f"File from {skill_name} skill", - mime_type=mime_type or "application/octet-stream", - skill_info=skill, - file_path=file_path, - ) - - return None - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """List resource templates for accessing files within the skill.""" - # Only expose template if supporting_files="template" - if self._supporting_files != "template": - return [] - - skill = self.skill_info - return [ - SkillFileTemplate( - uri_template=f"skill://{skill.name}/{{path*}}", - name=f"{skill.name}_files", - description=f"Access files within {skill.name}", - mime_type="application/octet-stream", - parameters={ - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"], - }, - skill_info=skill, - ) - ] - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get a resource template that matches the given URI.""" - # Only match if supporting_files="template" - if self._supporting_files != "template": - return None - - skill = self.skill_info - - if not uri.startswith("skill://"): - return None - - path_part = uri[len("skill://") :] - parts = path_part.split("/", 1) - if len(parts) != 2: - return None - - skill_name, file_path = parts - if skill_name != skill.name: - return None - - # Don't match known resources (main file, manifest) - if file_path == "_manifest" or file_path == self._main_file_name: - return None - - return SkillFileTemplate( - uri_template=f"skill://{skill.name}/{{path*}}", - name=f"{skill.name}_files", - description=f"Access files within {skill.name}", - mime_type="application/octet-stream", - parameters={ - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"], - }, - skill_info=skill, - ) - - def __repr__(self) -> str: - return ( - f"SkillProvider(skill_path={self._skill_path!r}, " - f"supporting_files={self._supporting_files!r})" - ) +__all__ = ["SkillProvider"] diff --git a/src/fastmcp/server/providers/skills/vendor_providers.py b/src/fastmcp/server/providers/skills/vendor_providers.py index df870f161..d39d7930b 100644 --- a/src/fastmcp/server/providers/skills/vendor_providers.py +++ b/src/fastmcp/server/providers/skills/vendor_providers.py @@ -1,142 +1,38 @@ -"""Vendor-specific skills providers for various AI coding platforms.""" +"""Deprecation shim — vendor skills providers moved to `fastmcp.server.plugins.skills.vendor_providers`. -from __future__ import annotations +Prefer `Skills(SkillsConfig(vendor=""))` over the individual +vendor subclasses — one plugin entry replaces the seven hardcoded +classes. +""" -from pathlib import Path -from typing import Literal +import warnings -from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.skills.vendor_providers import ( + CodexSkillsProvider, + CopilotSkillsProvider, + CursorSkillsProvider, + GeminiSkillsProvider, + GooseSkillsProvider, + OpenCodeSkillsProvider, + VSCodeSkillsProvider, +) +warnings.warn( + "fastmcp.server.providers.skills.vendor_providers has moved to " + "fastmcp.server.plugins.skills.vendor_providers. Prefer the Skills " + 'plugin: `Skills(SkillsConfig(vendor=""))`. This old ' + "leaf-submodule import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, +) -class CursorSkillsProvider(SkillsDirectoryProvider): - """Cursor skills from ~/.cursor/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".cursor" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class VSCodeSkillsProvider(SkillsDirectoryProvider): - """VS Code skills from ~/.copilot/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".copilot" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class CodexSkillsProvider(SkillsDirectoryProvider): - """Codex skills from /etc/codex/skills/ and ~/.codex/skills/. - - Scans both system-level and user-level directories. System skills take - precedence if duplicates exist. - """ - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - system_root = Path("/etc/codex/skills") - user_root = Path.home() / ".codex" / "skills" - - # Include both paths (system first, then user) - roots = [system_root, user_root] - - super().__init__( - roots=roots, - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class GeminiSkillsProvider(SkillsDirectoryProvider): - """Gemini skills from ~/.gemini/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".gemini" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class GooseSkillsProvider(SkillsDirectoryProvider): - """Goose skills from ~/.config/agents/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".config" / "agents" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class CopilotSkillsProvider(SkillsDirectoryProvider): - """GitHub Copilot skills from ~/.copilot/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".copilot" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class OpenCodeSkillsProvider(SkillsDirectoryProvider): - """OpenCode skills from ~/.config/opencode/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".config" / "opencode" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) +__all__ = [ + "CodexSkillsProvider", + "CopilotSkillsProvider", + "CursorSkillsProvider", + "GeminiSkillsProvider", + "GooseSkillsProvider", + "OpenCodeSkillsProvider", + "VSCodeSkillsProvider", +] diff --git a/tests/server/plugins/test_skills_plugin.py b/tests/server/plugins/test_skills_plugin.py new file mode 100644 index 000000000..c3a82b4dd --- /dev/null +++ b/tests/server/plugins/test_skills_plugin.py @@ -0,0 +1,133 @@ +"""Tests for the Skills plugin wrapper. + +Provider behavior (skill discovery, file exposure, etc.) is covered by +`test_skills_provider.py` and `test_skills_vendor_providers.py`. This +file only covers plugin-layer concerns — config validation, meta, +vendor→path resolution, and the deprecation shim at the old import path. +""" + +from __future__ import annotations + +import warnings +from pathlib import Path +from typing import cast + +import pytest +from pydantic import ValidationError + +from fastmcp.server.plugins.skills import Skills, SkillsConfig +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider +from fastmcp.server.plugins.skills.plugin import VENDOR_PATHS, Vendor +from fastmcp.server.plugins.skills.skill_provider import SkillProvider + + +class TestSkillsConfig: + def test_config_generic_binding(self): + assert Skills._config_cls is SkillsConfig + + def test_default_config_instantiable(self): + """Defaults must pass the plugin framework's instantiate-with-no-args + contract; the source check fires at providers() time.""" + assert SkillsConfig() # must not raise + + def test_unknown_config_key_rejected(self): + with pytest.raises((ValidationError, Exception), match="forbid|extra"): + SkillsConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + + def test_default_meta(self): + assert Skills.meta.name == "skills" + assert Skills.meta.version is None + + +class TestSourceResolution: + def test_path_source_builds_skill_provider(self, tmp_path: Path): + skill = tmp_path / "my-skill" + skill.mkdir() + (skill / "SKILL.md").write_text("# My Skill") + + plugin = Skills(SkillsConfig(path=str(skill))) + providers = plugin.providers() + assert isinstance(providers[0], SkillProvider) + + def test_directory_source_builds_directory_provider(self, tmp_path: Path): + plugin = Skills(SkillsConfig(directory=str(tmp_path))) + providers = plugin.providers() + assert isinstance(providers[0], SkillsDirectoryProvider) + + def test_directory_source_accepts_list(self, tmp_path: Path): + a, b = tmp_path / "a", tmp_path / "b" + a.mkdir() + b.mkdir() + plugin = Skills(SkillsConfig(directory=[str(a), str(b)])) + providers = plugin.providers() + assert isinstance(providers[0], SkillsDirectoryProvider) + + @pytest.mark.parametrize("vendor", list(VENDOR_PATHS)) + def test_vendor_presets_resolve_to_known_paths(self, vendor: str): + """Every vendor string must produce a directory provider rooted + at the paths the old vendor subclass used to hardcode.""" + plugin = Skills(SkillsConfig(vendor=cast(Vendor, vendor))) + providers = plugin.providers() + assert isinstance(providers[0], SkillsDirectoryProvider) + + def test_no_source_fails_at_build_time(self): + plugin = Skills(SkillsConfig()) + with pytest.raises(ValueError, match="path.*directory.*vendor"): + plugin.providers() + + def test_multiple_sources_rejected(self, tmp_path: Path): + plugin = Skills(SkillsConfig(directory=str(tmp_path), vendor="claude")) + with pytest.raises(ValueError, match="exactly one"): + plugin.providers() + + +class TestDeprecationShim: + """The old `fastmcp.server.providers.skills` package shims back to the + new plugin package. Top-level stays silent; leaf submodule imports + emit `FastMCPDeprecationWarning`.""" + + def test_top_level_is_silent(self): + import importlib + import sys + + from fastmcp.exceptions import FastMCPDeprecationWarning + + sys.modules.pop("fastmcp.server.providers.skills", None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.import_module("fastmcp.server.providers.skills") + + fastmcp_warns = [ + w for w in caught if issubclass(w.category, FastMCPDeprecationWarning) + ] + assert not fastmcp_warns + + def test_leaf_submodule_import_emits_deprecation_warning(self): + import importlib + import sys + + from fastmcp.exceptions import FastMCPDeprecationWarning + + sys.modules.pop("fastmcp.server.providers.skills.vendor_providers", None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + importlib.import_module("fastmcp.server.providers.skills.vendor_providers") + + fastmcp_warns = [ + w for w in caught if issubclass(w.category, FastMCPDeprecationWarning) + ] + assert any("plugins.skills" in str(w.message) for w in fastmcp_warns) + + def test_old_import_path_symbols_still_resolve(self): + """`ClaudeSkillsProvider` and friends keep resolving through the + silent package-level shim.""" + from fastmcp.server.plugins.skills.claude_provider import ( + ClaudeSkillsProvider as NewClass, + ) + from fastmcp.server.providers.skills import ( + ClaudeSkillsProvider as OldClass, + ) + + assert OldClass is NewClass diff --git a/tests/server/providers/test_skills_provider.py b/tests/server/plugins/test_skills_provider.py similarity index 97% rename from tests/server/providers/test_skills_provider.py rename to tests/server/plugins/test_skills_provider.py index 97732f308..9b0534101 100644 --- a/tests/server/providers/test_skills_provider.py +++ b/tests/server/plugins/test_skills_provider.py @@ -8,13 +8,14 @@ from mcp.types import TextResourceContents from pydantic import AnyUrl from fastmcp import Client, FastMCP -from fastmcp.server.providers.skills import ( - ClaudeSkillsProvider, - SkillProvider, - SkillsDirectoryProvider, - SkillsProvider, -) -from fastmcp.server.providers.skills._common import parse_frontmatter +from fastmcp.server.plugins.skills._common import parse_frontmatter +from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider +from fastmcp.server.plugins.skills.skill_provider import SkillProvider + +# `SkillsProvider` was a backcompat alias for `SkillsDirectoryProvider` +# in the old providers/ package — preserve that shape for these tests. +SkillsProvider = SkillsDirectoryProvider class TestParseFrontmatter: diff --git a/tests/server/providers/test_skills_vendor_providers.py b/tests/server/plugins/test_skills_vendor_providers.py similarity index 98% rename from tests/server/providers/test_skills_vendor_providers.py rename to tests/server/plugins/test_skills_vendor_providers.py index 65959ef1d..0c897466e 100644 --- a/tests/server/providers/test_skills_vendor_providers.py +++ b/tests/server/plugins/test_skills_vendor_providers.py @@ -4,8 +4,8 @@ from __future__ import annotations from pathlib import Path -from fastmcp.server.providers.skills import ( - ClaudeSkillsProvider, +from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider +from fastmcp.server.plugins.skills.vendor_providers import ( CodexSkillsProvider, CopilotSkillsProvider, CursorSkillsProvider, diff --git a/tests/utilities/test_skills.py b/tests/utilities/test_skills.py index 46170529d..3fbba3d45 100644 --- a/tests/utilities/test_skills.py +++ b/tests/utilities/test_skills.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest from fastmcp import Client, FastMCP -from fastmcp.server.providers.skills import SkillsDirectoryProvider +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider from fastmcp.utilities.skills import ( SkillFile, SkillManifest, From 5dc7baa5a81b6916c017cdbadf5385ebee57eb9f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 4 May 2026 12:09:33 -0400 Subject: [PATCH 14/17] Add plugin auth hook and install-time contributions (#4022) --- src/fastmcp/server/mixins/lifespan.py | 15 +- src/fastmcp/server/mixins/transport.py | 1 - src/fastmcp/server/plugins/base.py | 108 ++++-- src/fastmcp/server/server.py | 281 ++++++---------- tests/server/plugins/test_auth_hook.py | 248 ++++++++++++++ tests/server/test_plugins.py | 444 +++++++++---------------- 6 files changed, 603 insertions(+), 494 deletions(-) create mode 100644 tests/server/plugins/test_auth_hook.py diff --git a/src/fastmcp/server/mixins/lifespan.py b/src/fastmcp/server/mixins/lifespan.py index 951a2bbc6..d3ad127b0 100644 --- a/src/fastmcp/server/mixins/lifespan.py +++ b/src/fastmcp/server/mixins/lifespan.py @@ -171,13 +171,14 @@ class LifespanMixin: self._lifespan_result = user_lifespan_result self._lifespan_result_set = True - # Plugin entry pass: each registered plugin's `run()` async - # context manager wraps the server's lifespan. Runs before - # provider lifespans and `_started` because plugins may - # contribute providers. Partial-failure safety is automatic - # — AsyncExitStack only unwinds plugin contexts that were - # successfully entered, so a raising plugin doesn't tear - # down plugins that never entered. + # Plugin runtime pass: each registered plugin's `run()` async + # context manager wraps the server's lifespan. Contributions + # were already installed at add_plugin() time, so this only + # enters async runtime work before provider lifespans and + # `_started`. Partial-failure safety is automatic — + # AsyncExitStack only unwinds plugin contexts that were + # successfully entered, so a raising plugin doesn't tear down + # plugins that never entered. await self._enter_plugin_contexts(stack) # Start lifespans for all providers diff --git a/src/fastmcp/server/mixins/transport.py b/src/fastmcp/server/mixins/transport.py index 3c87a23e2..b28f7e7d9 100644 --- a/src/fastmcp/server/mixins/transport.py +++ b/src/fastmcp/server/mixins/transport.py @@ -333,7 +333,6 @@ class TransportMixin: Returns: A Starlette application configured with the specified transport """ - if transport in ("streamable-http", "http"): return create_streamable_http_app( server=self, diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py index e6bb9762c..8851e22b1 100644 --- a/src/fastmcp/server/plugins/base.py +++ b/src/fastmcp/server/plugins/base.py @@ -38,6 +38,7 @@ from typing_extensions import Self import fastmcp from fastmcp.exceptions import FastMCPError +from fastmcp.server.auth.auth import AuthProvider from fastmcp.server.middleware import Middleware from fastmcp.server.providers import Provider from fastmcp.server.transforms import Transform @@ -426,12 +427,12 @@ class Plugin(Generic[C]): # trivially. cls._validate_config_cls(cls._config_cls) - # Framework-internal marker. Set to True by `FastMCP.add_plugin` when - # the plugin is added from inside another plugin's setup() (the loader - # pattern). The server removes ephemeral plugins and their - # contributions on teardown so loaders don't accumulate duplicates - # across lifespan cycles. - _fastmcp_ephemeral: bool = False + # Framework-internal: the server this plugin is attached to, or None + # if not yet installed. Set by `install()` and checked to enforce the + # "one plugin instance per server" contract — plugin instances are + # single-server by design so plugin authors don't have to reason about + # per-server state isolation for contributions and lifecycle hooks. + _installed_on: FastMCP | None = None def __init__(self, config: C | dict[str, Any] | None = None) -> None: meta = getattr(type(self), "meta", None) @@ -615,23 +616,58 @@ class Plugin(Generic[C]): # -- lifecycle ------------------------------------------------------------ + def on_install(self, server: FastMCP) -> None: + """Optional sync hook run when the plugin is attached to a server. + + The framework calls this exactly once, from inside + `FastMCP.add_plugin()`, after the plugin has been recorded in the + server's plugin list but before its contribution hooks are + pulled. Default: no-op. + + Override to do server-aware synchronous setup — stash a server + reference, compute derived state that contribution hooks depend + on, or recursively register child plugins (the "loader" pattern): + + ```python + class ConfigLoader(Plugin[LoaderConfig]): + def on_install(self, server): + for spec in self.config.children: + server.add_plugin(build_plugin(spec)) + ``` + + Child plugins registered from `on_install` appear after this + plugin in `server.plugins`, preserving parent-before-child + registration order. + + Async setup belongs in `run()` / `setup()`, not here. `on_install` + is synchronous because it runs inside `FastMCP.__init__` before + any event loop exists. + """ + @asynccontextmanager async def run(self, server: FastMCP) -> AsyncIterator[None]: - """Async context manager wrapping the plugin's lifetime. + """Async context manager wrapping the plugin's runtime lifetime. + + Used for **async work only** — the plugin's contributions are + already installed by the time `run()` is entered. Opening database + connections, starting background tasks, hydrating an + already-installed provider with live state: all fine. Registering + additional plugins, middleware, or providers here is discouraged: + use `on_install()` for plugin composition so the server graph is + configured before runtime work begins. The framework enters `async with plugin.run(server):` on the - server's lifespan stack. Everything before the `yield` runs - during startup (in plugin registration order); the `yield` spans - the server's active lifetime; everything after the `yield` runs - on shutdown (in reverse registration order). Cancellation on - shutdown unwinds the context manager automatically. + server's lifespan stack once per lifespan cycle. Everything before + the `yield` runs during startup (in registration order); the + `yield` spans the server's active lifetime; everything after + runs on shutdown (reverse order). The default implementation calls `setup(server)` before the `yield` and `teardown()` after it, so plugins that just need - one-shot init/cleanup can keep overriding just those two - methods. Long-running plugins (channels, integration bridges, - background workers) override `run()` directly to use - `async with` for resource management and task groups: + one-shot init/cleanup can keep overriding just those two methods. + Long-running plugins (channels, integration bridges, background + workers) override `run()` directly to use `async with` for + resource management and task groups: @asynccontextmanager async def run(self, server): @@ -656,10 +692,14 @@ class Plugin(Generic[C]): """One-shot async initialization. Called by the default `run()` before the `yield`. - Override for simple init work — compile regexes, warm caches, - open connections, register additional plugins from a loader. For - anything involving long-lived resources or background tasks, - override `run()` directly instead and use `async with`. + Override for simple async init work — open connections, warm + caches, hydrate an already-installed provider. For anything + involving long-lived resources or background tasks, override + `run()` directly instead and use `async with`. + + Prefer `on_install()` for registering additional plugins or + contributions so plugin composition happens at install time, before + async runtime work begins. """ async def teardown(self) -> None: @@ -685,6 +725,34 @@ class Plugin(Generic[C]): """Return component providers.""" return [] + def auth(self) -> AuthProvider | None: + """Return the auth provider this plugin contributes, or `None`. + + Any `AuthProvider` subclass is accepted — a `TokenVerifier`, a + full OAuth server (`OAuthProvider` / `RemoteAuthProvider` / + `OAuthProxy`), or a pre-composed `MultiAuth`. + + FastMCP's auth slot is **singular**. Across the user-declared + `auth=` and every plugin's `auth()` return, at most one + `AuthProvider` may be active. Multiple contributors raise + `PluginError` with an error that names every source so the + operator can resolve the conflict explicitly. + + **Best practice for plugins that contribute auth**: expose a + config knob (conventionally `enable_auth: bool = True`) so users + who want the plugin's other features but prefer different auth + can disable it without framework-level composition rules. Return + `None` when the knob is off. + + For genuine multi-source auth, users construct a `MultiAuth` + explicitly and pass it as the single `auth=` arg — the framework + never auto-composes, because silent composition produces + surprising behavior at token-verification time. + + The default returns `None`. + """ + return None + def capabilities(self) -> dict[str, Any]: """Return a partial `ServerCapabilities` dict to merge into the server's capabilities. diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 75e0a258e..33c2347c1 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -391,6 +391,14 @@ class FastMCP( ) self.auth: AuthProvider | None = auth + # Identifies where `self.auth` came from, for a clear error if a + # plugin later contributes a second auth provider. `None` while + # `self.auth` is `None`; set to `"user-declared auth="` when the + # caller passes `auth=...` and to `"plugin ''"` when a + # plugin contributes one. + self._auth_source: str | None = ( + "user-declared auth=" if auth is not None else None + ) if tools: for tool in tools: @@ -420,28 +428,11 @@ class FastMCP( self.middleware.append(DereferenceRefsMiddleware()) # Plugin registry: an ordered list, populated by `add_plugin()` and - # `plugins=[...]`. Each plugin's `run()` async context manager wraps - # the server's lifespan (see `_enter_plugin_contexts`). + # `plugins=[...]`. Plugins install their contributions synchronously + # at `add_plugin()` time; each plugin's `run()` async context manager + # wraps the server's lifespan (see `_enter_plugin_contexts`). self.plugins: list[Plugin] = [] - # Plugins whose contributions (middleware/transforms/providers/routes) - # have been collected onto the server. Server contributions persist - # across lifespan cycles like server-authored middleware, so we - # don't re-install them on re-entry. - self._plugins_contributed: set[int] = set() - # Plugins whose `run()` context has been entered in the current - # lifespan cycle. Used to dedupe entry when the same instance is - # registered twice; reset after the cycle's ephemeral cleanup. - self._plugins_entered: set[int] = set() - # Per-plugin record of contributions we installed, stored as - # (container, item) tuples so we can reverse them when an - # ephemeral plugin is torn down. - self._plugin_contributions: dict[int, list[tuple[list[Any], Any]]] = {} - # True while the plugin-entry loop is running. `add_plugin()` uses - # this to flag ephemeral plugins — plugins added from inside - # another plugin's `run()` (the loader pattern). Ephemeral plugins - # are removed after all plugin contexts exit so loaders can - # freshly re-hydrate children on the next lifespan cycle. - self._in_plugin_setup_pass: bool = False + self._plugin_capabilities: dict[int, dict[str, Any]] = {} for p in plugins or []: self.add_plugin(p) @@ -512,182 +503,108 @@ class FastMCP( def add_plugin(self, plugin: Plugin) -> None: """Register a plugin with this server. - Appends the plugin to the server's ordered plugin list and - synchronously collects its HTTP routes (see below). Middleware, - transforms, and providers are collected later, during the - server's startup sequence, because those hooks may reference - state the plugin populates while its `run()` context is active. + Collects all of the plugin's contributions — providers, middleware, + transforms, routes, auth, capabilities — synchronously, once, at + registration time. Plugin lifecycle (`run()` / `setup()` / + `teardown()`) is strictly for async runtime work. - HTTP routes are collected eagerly because HTTP transports snapshot - the server's route list when they construct the Starlette app — - which happens before the lifespan runs. A route returned by - `plugin.routes()` after the app is built would sit in - `_additional_http_routes` but never be mounted and would always - 404. Collecting at registration time keeps non-loader plugins - working for HTTP transports. + The plugin's `on_install(server)` hook runs first, which enforces the + "one plugin instance per server" contract (plugins are single- + server by design — construct a fresh instance for each server + rather than sharing). After `on_install()` returns, contribution + hooks are called and their results are applied. If any step + raises, registration fails loudly; the caller should discard the + partially configured server rather than expect best-effort + recovery. - Loader caveat: plugins added from inside another plugin's - `run()` (the loader pattern) can still contribute middleware, - transforms, and providers, but their routes may not be reachable - over HTTP/SSE transports — those transports' route lists are - already fixed by the time `run()` enters. Loaders that need to - contribute routes should use the stdio transport or expose the - routes via a non-loader plugin registered at construction time. + Dynamic plugin loading (the "loader" pattern) is supported via + `Plugin.on_install(server)`, which may call `server.add_plugin()` + recursively. That path runs entirely at registration time, so + the resulting plugin tree is installed before runtime work starts. Args: - plugin: A :class:`Plugin` instance. Plugins are registered in - the order they are added; middleware is a stack. + plugin: A :class:`Plugin` instance. Raises: - PluginError: If called after the server's plugin-entry pass - has completed (except from inside a loader plugin's - `run()`), or if the plugin's `fastmcp_version` - compatibility check fails. + PluginError: If the plugin is already installed on a server, + if the plugin's `fastmcp_version` compatibility check + fails, or if another source has already contributed auth. """ - # Reject registration once the lifespan is active and we're past - # the plugin-entry pass. The loader-pattern exception is the only - # case where add_plugin() runs during a live lifespan, and it's - # gated by `_in_plugin_setup_pass`. Checking `_started` alone is - # too narrow: `_started` is only set after provider lifespans - # enter and is cleared before teardown, leaving windows in which - # add_plugin() would silently register a plugin whose `run()` - # never enters for the current cycle. - if self._lifespan_result_set and not self._in_plugin_setup_pass: - raise PluginError( - f"Cannot add plugin {plugin.meta.name!r}: the server's " - "plugin-entry pass has already completed. Register " - "plugins before the server starts, or from inside " - "another plugin's `run()` (the loader pattern)." - ) plugin.check_fastmcp_compatibility() - # Compute routes up front so a failure inside plugin.routes() does - # not leave a half-registered plugin in self.plugins. - routes = list(plugin.routes()) + if plugin._installed_on is not None: + raise PluginError( + f"Plugin {plugin.meta.name!r} is already installed on " + f"{plugin._installed_on.name!r}. Plugin instances are " + "single-server by design — construct a fresh instance " + "per server rather than sharing one across servers." + ) + + # Attach and append FIRST so any children registered recursively + # by `on_install` land after this plugin in `self.plugins`, + # preserving parent-before-child registration order in the plugin + # list. Child contribution side effects still occur when the + # child is added from on_install. + plugin._installed_on = self self.plugins.append(plugin) - # Flag loader-added plugins as ephemeral so teardown can remove - # them along with their contributions. Written unconditionally so - # re-registering an instance that was previously marked ephemeral - # (added inside a setup pass and then cleaned up) as a permanent - # plugin clears the stale marker rather than inheriting it. - plugin._fastmcp_ephemeral = self._in_plugin_setup_pass - records = self._plugin_contributions.setdefault(id(plugin), []) - for route in routes: + + plugin.on_install(self) + + # Gather everything up front: any hook that raises must not have + # mutated server state. Auth is the only hook that can conflict + # with prior server state (the singular-auth-slot rule), so we + # validate it before committing anything else. + contributed_auth = plugin.auth() + contributed_capabilities = plugin.capabilities() + contributed_mws = list(plugin.middleware()) + contributed_transforms = list(plugin.transforms()) + contributed_providers = list(plugin.providers()) + contributed_routes = list(plugin.routes()) + + if contributed_auth is not None and self.auth is not None: + prior = self._auth_source or "user-declared auth=" + raise PluginError( + f"Multiple auth sources declared: {prior}, " + f"plugin {plugin.meta.name!r}. FastMCP accepts a " + "single auth provider. Disable auth on all but one " + "source (typically via the plugin's config), or " + "construct a `MultiAuth` explicitly in Python and " + "pass it as the single `auth=` arg." + ) + + # Commit. From here we do not raise. + self._plugin_capabilities[id(plugin)] = contributed_capabilities + for mw in contributed_mws: + self.add_middleware(mw) + for transform in contributed_transforms: + self.add_transform(transform) + for provider in contributed_providers: + self.add_provider(provider) + for route in contributed_routes: self._additional_http_routes.append(route) - records.append((self._additional_http_routes, route)) + if contributed_auth is not None: + self.auth = contributed_auth + self._auth_source = f"plugin {plugin.meta.name!r}" async def _enter_plugin_contexts(self, stack: AsyncExitStack) -> None: """Enter each registered plugin's `run()` context on the given stack. - Called during server startup (from `_lifespan_manager`), before - the server binds. Iterates the plugin list in order, entering - `async with plugin.run(server):` on the shared exit stack for - each plugin. Plugins added during another plugin's `run()` (the - loader pattern) are picked up by the same loop because the - iteration advances against a live index. + Called once per server lifespan. Contributions were collected when + each plugin was registered via `add_plugin()`. All this loop does + is wrap each plugin's async runtime lifetime around the server's + lifespan. - Contributions (middleware, transforms, providers) are collected - once per plugin across all lifespan cycles — once installed, they - persist on the server just like server-authored middleware. - Routes were already collected synchronously at `add_plugin()` - time so HTTP transports see them before the lifespan runs. - - Ephemeral cleanup is registered as a post-stack callback so it - runs after every plugin's `run()` has exited but before the - outer server lifespan unwinds further. + Order: registration order on entry, reverse order on exit (the + exit stack handles the reversal automatically). Exceptions inside + a plugin's `run()` body unwind already-entered contexts cleanly. """ - # Register ephemeral cleanup FIRST so it unwinds LAST (after all - # plugin.run() contexts have exited). - stack.push_async_callback(self._cleanup_ephemeral_plugins) - - # Plugin-entry loop: mutating-list iteration. New plugins appended - # by a plugin's run() (loader pattern) are picked up on subsequent - # iterations. `_in_plugin_setup_pass` lets add_plugin() mark those - # as ephemeral (see add_plugin). `_plugins_entered` dedupes: a - # plugin instance registered twice only enters its run() once per - # lifespan cycle, keeping setup/teardown counts symmetric. - self._in_plugin_setup_pass = True - try: - i = 0 - while i < len(self.plugins): - plugin = self.plugins[i] - i += 1 - if id(plugin) in self._plugins_entered: - continue - self._plugins_entered.add(id(plugin)) - try: - await stack.enter_async_context(plugin.run(self)) - except Exception: - logger.exception( - "Plugin %r raised while entering run()", plugin.meta.name - ) - raise - finally: - self._in_plugin_setup_pass = False - - # Contribution collection: run in registration order. Guarded - # per-plugin because contributions persist across lifespan cycles; - # new plugins (e.g. added by a loader during this cycle's run) - # still get their contributions collected. Routes are collected - # synchronously at add_plugin() time (see above). for plugin in self.plugins: - if id(plugin) in self._plugins_contributed: - continue - # Gather everything first: any hook that raises aborts before - # any server state is mutated, so a retry on the next lifespan - # cycle starts clean rather than appending duplicate middleware - # on top of partial contributions. - mws = list(plugin.middleware()) - transforms_ = list(plugin.transforms()) - providers_ = list(plugin.providers()) - - records = self._plugin_contributions.setdefault(id(plugin), []) - for mw in mws: - self.add_middleware(mw) - records.append((self.middleware, mw)) - for transform in transforms_: - self.add_transform(transform) - records.append((self._transforms, transform)) - for provider in providers_: - # add_provider may wrap the value (for example a FastMCP - # is wrapped in FastMCPProvider). Record whatever - # actually landed in self.providers so teardown can find - # it by identity. - before = len(self.providers) - self.add_provider(provider) - for stored in self.providers[before:]: - records.append((self.providers, stored)) - self._plugins_contributed.add(id(plugin)) - - async def _cleanup_ephemeral_plugins(self) -> None: - """Remove ephemeral (loader-added) plugins after all contexts have exited. - - Runs as an `AsyncExitStack` callback registered in - `_enter_plugin_contexts` so it fires after every plugin's `run()` - has unwound. Without this, each lifespan cycle would accumulate a - fresh generation of loader-added children in `self.plugins`, and - their contributions would accumulate in the server's - middleware/transform/provider lists. - """ - # Reset the per-cycle entered set for the next cycle. - self._plugins_entered = set() - - ephemeral = [p for p in self.plugins if getattr(p, "_fastmcp_ephemeral", False)] - for plugin in ephemeral: - records = self._plugin_contributions.pop(id(plugin), []) - for container, item in reversed(records): - # Remove by identity rather than equality so a permanent - # contribution that happens to compare equal to `item` - # (e.g. a dataclass-style middleware with value-based - # `__eq__`) is not accidentally stripped. list.remove() - # uses `==`, which is the wrong matcher here. - for i, entry in enumerate(container): - if entry is item: - del container[i] - break - self._plugins_contributed.discard(id(plugin)) - self.plugins = [ - p for p in self.plugins if not getattr(p, "_fastmcp_ephemeral", False) - ] + try: + await stack.enter_async_context(plugin.run(self)) + except Exception: + logger.exception( + "Plugin %r raised while entering run()", plugin.meta.name + ) + raise def _apply_plugin_capabilities( self, capabilities: mcp.types.ServerCapabilities @@ -702,7 +619,9 @@ class FastMCP( update, applied recursively. Plugins that return an empty dict contribute nothing. """ - contributions = [plugin.capabilities() for plugin in self.plugins] + contributions = [ + self._plugin_capabilities.get(id(plugin), {}) for plugin in self.plugins + ] if not any(contributions): return capabilities diff --git a/tests/server/plugins/test_auth_hook.py b/tests/server/plugins/test_auth_hook.py new file mode 100644 index 000000000..3400b9b60 --- /dev/null +++ b/tests/server/plugins/test_auth_hook.py @@ -0,0 +1,248 @@ +"""Tests for the `Plugin.auth()` contribution hook (FMCP-24). + +Semantic rule: FastMCP's auth slot is singular. `auth=` + every plugin's +`auth()` return are collected; at most one `AuthProvider` may be active. +Multiple sources raise `PluginError` — no automatic `MultiAuth` wrapping. +Users who want multi-source auth build `MultiAuth` explicitly. +""" + +from __future__ import annotations + +import pytest + +from fastmcp import FastMCP +from fastmcp.server.auth.auth import AuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import StaticTokenVerifier +from fastmcp.server.plugins.base import Plugin, PluginError, PluginMeta + + +def _verifier(token: str = "t") -> TokenVerifier: + return StaticTokenVerifier(tokens={token: {"client_id": "c", "scopes": []}}) + + +class _FakeServerAuth(AuthProvider): + """Minimal non-TokenVerifier AuthProvider — stands in for an OAuth + server in tests without needing a real issuer URL.""" + + def __init__(self, base_url: str = "https://example.com") -> None: + super().__init__(base_url=base_url) + + async def verify_token(self, token): # type: ignore[override] + return None + + +class TestDefaultHook: + def test_plugin_auth_defaults_to_none(self): + class P(Plugin): + meta = PluginMeta(name="p") + + assert P().auth() is None + + +class TestSingleSource: + def test_lone_plugin_contribution_becomes_self_auth(self): + """One plugin contributing one AuthProvider, no user `auth=` → that + provider is installed directly as `self.auth`. No wrapping, no + lifespan round-trip: `self.auth` is set synchronously at + `add_plugin` time so HTTP/SSE transports see it when they build + the Starlette app.""" + v = _verifier() + + class P(Plugin): + meta = PluginMeta(name="p") + + def auth(self) -> AuthProvider | None: + return v + + mcp = FastMCP("t", plugins=[P()]) + assert mcp.auth is v + + def test_user_declared_alone_untouched(self): + """No plugin contributing auth → `self.auth` is exactly the user + value, no processing.""" + user_v = _verifier() + mcp = FastMCP("t", auth=user_v) + assert mcp.auth is user_v + + def test_no_sources_leaves_auth_none(self): + mcp = FastMCP("t") + assert mcp.auth is None + + def test_add_plugin_installs_auth(self): + """Plugin added after construction installs its auth synchronously.""" + v = _verifier() + mcp = FastMCP("t") + assert mcp.auth is None + + class P(Plugin): + meta = PluginMeta(name="p") + + def auth(self) -> AuthProvider | None: + return v + + mcp.add_plugin(P()) + assert mcp.auth is v + + +class TestMultipleSourcesRejected: + """FastMCP's auth slot is singular. Multiple contributors raise.""" + + def test_two_plugin_verifiers_raises(self): + v1, v2 = _verifier("one"), _verifier("two") + + class P1(Plugin): + meta = PluginMeta(name="p1") + + def auth(self) -> AuthProvider | None: + return v1 + + class P2(Plugin): + meta = PluginMeta(name="p2") + + def auth(self) -> AuthProvider | None: + return v2 + + with pytest.raises(PluginError, match="Multiple auth sources"): + FastMCP("t", plugins=[P1(), P2()]) + + def test_user_plus_plugin_raises(self): + """User-declared `auth=` + any plugin contribution is ambiguous — + framework doesn't silently pick a winner.""" + user_v, plugin_v = _verifier("u"), _verifier("p") + + class P(Plugin): + meta = PluginMeta(name="p") + + def auth(self) -> AuthProvider | None: + return plugin_v + + with pytest.raises(PluginError, match="Multiple auth sources"): + FastMCP("t", auth=user_v, plugins=[P()]) + + def test_two_server_contributions_raises(self): + """Also covers the server-server case (historical multiauth reason).""" + s1 = _FakeServerAuth("https://a.example") + s2 = _FakeServerAuth("https://b.example") + + class P1(Plugin): + meta = PluginMeta(name="p1") + + def auth(self) -> AuthProvider | None: + return s1 + + class P2(Plugin): + meta = PluginMeta(name="p2") + + def auth(self) -> AuthProvider | None: + return s2 + + with pytest.raises(PluginError, match="Multiple auth sources"): + FastMCP("t", plugins=[P1(), P2()]) + + def test_error_names_conflicting_sources(self): + """Operator needs to know which sources conflict so they can + disable auth on all but one.""" + v1, v2 = _verifier("a"), _verifier("b") + + class Alpha(Plugin): + meta = PluginMeta(name="alpha") + + def auth(self) -> AuthProvider | None: + return v1 + + class Beta(Plugin): + meta = PluginMeta(name="beta") + + def auth(self) -> AuthProvider | None: + return v2 + + with pytest.raises(PluginError) as exc_info: + FastMCP("t", plugins=[Alpha(), Beta()]) + + msg = str(exc_info.value) + # The "prior" source (alpha) and the rejected plugin (beta) must + # both appear so operators can act on the conflict without + # re-running with extra logging. + assert "'alpha'" in msg + assert "'beta'" in msg + + +class TestAddPluginFailures: + def test_rejected_auth_conflict_raises_loudly(self): + """A plugin whose auth contribution conflicts raises immediately. + + Plugin installation is not transactional: after a failed install, + callers should discard the partially configured server rather than + expect FastMCP to recover arbitrary plugin mutations. + """ + v1 = _verifier("one") + + class P1(Plugin): + meta = PluginMeta(name="p1") + + def auth(self) -> AuthProvider | None: + return v1 + + class P2(Plugin): + meta = PluginMeta(name="p2") + + def __init__(self) -> None: + super().__init__() + self._v = _verifier("two") + + def auth(self) -> AuthProvider | None: + return self._v + + p1 = P1() + mcp = FastMCP("t", plugins=[p1]) + assert mcp.plugins == [p1] + assert mcp.auth is v1 + + p2 = P2() + with pytest.raises(PluginError, match="Multiple auth sources"): + mcp.add_plugin(p2) + + assert mcp.plugins == [p1, p2] + assert mcp.auth is v1 + assert p2._installed_on is mcp + + +class TestSingleServerPerInstance: + def test_same_instance_registered_twice_raises(self): + """A plugin instance belongs to one server — registering the same + instance twice (same server or different) raises PluginError.""" + v = _verifier() + + class P(Plugin): + meta = PluginMeta(name="p") + + def auth(self) -> AuthProvider | None: + return v + + p = P() + mcp = FastMCP("t", plugins=[p]) + assert mcp.auth is v + + with pytest.raises(PluginError, match="already installed"): + mcp.add_plugin(p) + + def test_plugins_kwarg_duplicate_instance_raises(self): + """Duplicate instance in the `plugins=` kwarg is caught the same way.""" + + class P(Plugin): + meta = PluginMeta(name="p") + + p = P() + with pytest.raises(PluginError, match="already installed"): + FastMCP("t", plugins=[p, p]) + + def test_instance_on_second_server_raises(self): + """Sharing a plugin instance across two servers is not supported.""" + + class P(Plugin): + meta = PluginMeta(name="p") + + p = P() + FastMCP("a", plugins=[p]) + with pytest.raises(PluginError, match="already installed"): + FastMCP("b", plugins=[p]) diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index 7687cc372..7ffdb17a9 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import json -from contextlib import suppress +from contextlib import asynccontextmanager, suppress from importlib import metadata as importlib_metadata from importlib.metadata import version as dist_version from pathlib import Path @@ -919,125 +919,106 @@ class TestLifecycle: ("teardown", "a"), ] - async def test_loader_pattern_adds_plugins_during_setup(self): - """A plugin's setup() can call server.add_plugin() and the setup pass sees it. - - Mid-cycle the loader-added children are present; after teardown - they're removed (ephemeral cleanup), so the loader can freshly - re-hydrate them on the next cycle. - """ + async def test_loader_pattern_adds_plugins_from_on_install(self): + """A plugin's `on_install(server)` can call `server.add_plugin()` + to register child plugins. The loader runs at registration time, + so child plugins are installed before any lifespan starts.""" recorder = _Recorder() - class Child(Plugin): - meta = PluginMeta(name="child", version="0.1.0") + class ChildA(Plugin): + meta = PluginMeta(name="child-a", version="0.1.0") async def setup(self, server): - recorder.events.append(("setup", "child")) + recorder.events.append(("setup", "child-a")) + + class ChildB(Plugin): + meta = PluginMeta(name="child-b", version="0.1.0") + + async def setup(self, server): + recorder.events.append(("setup", "child-b")) class Loader(Plugin): meta = PluginMeta(name="loader", version="0.1.0") - async def setup(self, server): - recorder.events.append(("setup", "loader")) - server.add_plugin(Child()) - server.add_plugin(Child()) + def on_install(self, server): + server.add_plugin(ChildA()) + server.add_plugin(ChildB()) mcp = FastMCP("t", plugins=[Loader()]) - async with Client(mcp) as c: - await c.ping() - # Mid-cycle, the loader's children are registered. - assert [p.meta.name for p in mcp.plugins] == [ - "loader", - "child", - "child", - ] - - assert recorder.events == [ - ("setup", "loader"), - ("setup", "child"), - ("setup", "child"), + # Children are registered at construction; no lifespan needed. + assert [p.meta.name for p in mcp.plugins] == [ + "loader", + "child-a", + "child-b", ] - # After teardown, ephemeral children have been removed. - assert [p.meta.name for p in mcp.plugins] == ["loader"] - - async def test_add_plugin_after_startup_raises(self): - class P(Plugin): - meta = PluginMeta(name="p", version="0.1.0") - - mcp = FastMCP("t") - async with Client(mcp) as c: - await c.ping() - with pytest.raises(PluginError, match="plugin-entry pass"): - mcp.add_plugin(P()) - - async def test_add_plugin_raises_when_called_from_provider_lifespan(self): - """Post-setup-pass registration must be rejected, not silently allowed. - - `_started` is set only after provider lifespans enter, so a - provider's `lifespan()` callback runs with `_started` False but - the plugin-entry pass already complete. Registering a plugin in - that window would skip `run()` and contribution collection for - the current cycle and leave the plugin in `self.plugins`; the - guard must reject it. - """ - from contextlib import asynccontextmanager - - from fastmcp.server.providers import Provider - - class PluginInProviderLifespan(Provider): - def __init__(self, server): - super().__init__() - self.server = server - self.raised: Exception | None = None - - @asynccontextmanager - async def lifespan(self): - class Late(Plugin): - meta = PluginMeta(name="late", version="0.1.0") - - try: - self.server.add_plugin(Late()) - except Exception as exc: - self.raised = exc - yield - - mcp = FastMCP("t") - provider = PluginInProviderLifespan(mcp) - mcp.add_provider(provider) async with Client(mcp) as c: await c.ping() - assert isinstance(provider.raised, PluginError) - assert "plugin-entry pass" in str(provider.raised) + # Children stay registered across the lifespan — they're permanent + # (registered during construction), not ephemeral. + assert [p.meta.name for p in mcp.plugins] == [ + "loader", + "child-a", + "child-b", + ] + assert ("setup", "child-a") in recorder.events + assert ("setup", "child-b") in recorder.events - async def test_duplicate_registration_tears_down_once(self): - """Registering the same instance twice must only call teardown() once. + def test_loader_child_contributions_install_during_on_install(self): + """Children registered by on_install install immediately. - setup() runs per list entry (so the plugin receives both entries), - but teardown() is an idempotent cleanup — a second call on a - plugin that has closed its resources would likely raise on an - already-closed connection. + The plugin list preserves parent-before-child order. Contribution + side effects happen when each plugin is added; FastMCP does not + defer or transact loader installs. """ - recorder = _Recorder() + + class Child(Plugin): + meta = PluginMeta(name="child", version="0.1.0") + + def middleware(self): + return [_TraceMiddleware("child")] + + class Loader(Plugin): + meta = PluginMeta(name="loader", version="0.1.0") + + def on_install(self, server): + server.add_plugin(Child()) + + def middleware(self): + return [_TraceMiddleware("loader")] + + mcp = FastMCP("t", plugins=[Loader()]) + + assert [p.meta.name for p in mcp.plugins] == ["loader", "child"] + tags = [m.tag for m in mcp.middleware if isinstance(m, _TraceMiddleware)] + assert tags == ["child", "loader"] + + def test_same_instance_registered_twice_raises(self): + """Plugin instances are single-server: a second registration of + the same instance (on any server) raises at `install()`.""" class P(Plugin): meta = PluginMeta(name="p", version="0.1.0") - async def teardown(self): - recorder.events.append(("teardown", "p")) - p = P() mcp = FastMCP("t") mcp.add_plugin(p) - mcp.add_plugin(p) - async with Client(mcp) as c: - await c.ping() + with pytest.raises(PluginError, match="already installed"): + mcp.add_plugin(p) - assert [e for e in recorder.events if e[0] == "teardown"] == [ - ("teardown", "p"), - ] + def test_instance_on_second_server_raises(self): + """Sharing a plugin instance across two servers is not supported.""" + + class P(Plugin): + meta = PluginMeta(name="p", version="0.1.0") + + p = P() + FastMCP("a", plugins=[p]) + + with pytest.raises(PluginError, match="already installed"): + FastMCP("b", plugins=[p]) async def test_teardown_exception_is_logged_not_raised(self): class Boom(Plugin): @@ -1135,52 +1116,35 @@ class TestLifecycle: # BadSetup never completed setup(); its teardown must not run. assert ("teardown", "bad") not in recorder.events - async def test_contribution_collection_is_atomic_when_later_hook_raises(self): - """A failing hook on one plugin must not leave partial contributions behind. + def test_add_plugin_raises_when_later_hook_raises(self): + """Contribution hook errors fail loudly. - If a plugin's ``middleware()`` succeeds but ``transforms()`` - raises, the middleware must not have been installed — otherwise a - retry on the next lifespan attempt would pick up the plugin - again (because we never marked it contributed) and append - duplicate middleware on top of the partial prior state. + Plugin installation is not transactional: plugin hooks can mutate + arbitrary server state, so FastMCP does not pretend to recover a + partially configured server. Callers should discard the server + after a failed plugin install. """ class Flaky(Plugin): meta = PluginMeta(name="flaky", version="0.1.0") - _fail: bool = True def middleware(self): return [_TraceMiddleware("flaky")] def transforms(self): - if Flaky._fail: - raise RuntimeError("transforms exploded") - return [] + raise RuntimeError("transforms exploded") - mcp = FastMCP("t", plugins=[Flaky()]) - baseline = list(mcp.middleware) + mcp = FastMCP("t") + flaky = Flaky() with pytest.raises(RuntimeError, match="transforms exploded"): - async with Client(mcp) as c: - await c.ping() + mcp.add_plugin(flaky) - # Partial state from the failed cycle must not have landed. - assert mcp.middleware == baseline + assert flaky in mcp.plugins + assert flaky._installed_on is mcp - # Retry succeeds; middleware is installed exactly once. - Flaky._fail = False - async with Client(mcp) as c: - await c.ping() - - tags = [m.tag for m in mcp.middleware if isinstance(m, _TraceMiddleware)] - assert tags == ["flaky"] - - async def test_add_plugin_is_atomic_when_routes_raises(self): - """If plugin.routes() raises, the plugin must not be left in the server's list. - - Otherwise a later startup would run the half-registered plugin's - lifecycle even though registration reported an error. - """ + async def test_add_plugin_raises_when_routes_raises(self): + """Route hook errors fail loudly without best-effort recovery.""" class RoutesBoom(Plugin): meta = PluginMeta(name="routes-boom", version="0.1.0") @@ -1189,104 +1153,19 @@ class TestLifecycle: raise RuntimeError("routes exploded") mcp = FastMCP("t") + plugin = RoutesBoom() + with pytest.raises(RuntimeError, match="routes exploded"): - mcp.add_plugin(RoutesBoom()) + mcp.add_plugin(plugin) - assert mcp.plugins == [] - # Contribution book-keeping for the failed plugin was never created. - # This is a weaker assertion — we just care the plugin isn't linger. - assert not any(isinstance(p, RoutesBoom) for p in mcp.plugins) + assert plugin in mcp.plugins + assert plugin._installed_on is mcp - async def test_ephemeral_fastmcp_provider_is_removed_on_teardown(self): - """Loader-added FastMCP providers are auto-wrapped; teardown must still remove them. - - ``add_provider`` wraps a FastMCP in a FastMCPProvider before it - lands in ``self.providers``. Recording the pre-wrap object would - cause teardown to miss the wrapped provider and leak it across - cycles. - """ - - class ProviderPlugin(Plugin): - meta = PluginMeta(name="wrapper", version="0.1.0") - - def __init__(self, config=None): - super().__init__(config) - self._child = FastMCP("child") - - def providers(self): - return [self._child] - - class Loader(Plugin): - meta = PluginMeta(name="loader", version="0.1.0") - - async def setup(self, server): - server.add_plugin(ProviderPlugin()) - - mcp = FastMCP("t", plugins=[Loader()]) - baseline_providers = list(mcp.providers) - - async with Client(mcp) as c: - await c.ping() - async with Client(mcp) as c: - await c.ping() - - assert [p.meta.name for p in mcp.plugins] == ["loader"] - # The wrapped provider that was added on each cycle was removed - # on each teardown — the provider list is back to baseline. - assert mcp.providers == baseline_providers - - async def test_ephemeral_cleanup_removes_by_identity_not_equality(self): - """A permanent contribution that compares equal to an ephemeral one is preserved. - - list.remove() uses `==`, which is the wrong matcher when a - middleware defines value-based equality. A loader-added middleware - that happens to `==` a user-registered middleware must not cause - the user's to be removed during ephemeral cleanup. - """ - - class EqMiddleware(Middleware): - """Middleware that compares equal to any other EqMiddleware.""" - - def __eq__(self, other): - return isinstance(other, EqMiddleware) - - def __hash__(self): - return 0 - - permanent = EqMiddleware() - - class Child(Plugin): - meta = PluginMeta(name="child", version="0.1.0") - - def middleware(self): - # A distinct instance, but equal to `permanent` by __eq__. - return [EqMiddleware()] - - class Loader(Plugin): - meta = PluginMeta(name="loader", version="0.1.0") - - async def setup(self, server): - server.add_plugin(Child()) - - mcp = FastMCP("t", middleware=[permanent], plugins=[Loader()]) - assert permanent in mcp.middleware - - async with Client(mcp) as c: - await c.ping() - - # The ephemeral child's middleware was removed; the permanent - # user-registered one (which was `==` to it) is still installed. - assert any(m is permanent for m in mcp.middleware) - - async def test_reregistering_ephemeral_instance_as_permanent_clears_marker(self): - """A previously-ephemeral instance re-registered by the user is permanent. - - Without clearing the marker on normal `add_plugin`, the second - registration would inherit `_fastmcp_ephemeral = True` from the - first (loader-added) cycle and get deleted during teardown, losing - its contributions. - """ - leaked: list[Plugin] = [] + async def test_on_install_loader_contributions_persist_across_cycles(self): + """Plugins registered via a loader's `on_install(server)` are permanent + just like plugins passed to `FastMCP(plugins=[...])`. Their + contributions are installed once and survive across lifespan + cycles without re-registration or cleanup.""" class Child(Plugin): meta = PluginMeta(name="child", version="0.1.0") @@ -1297,80 +1176,29 @@ class TestLifecycle: class Loader(Plugin): meta = PluginMeta(name="loader", version="0.1.0") - async def setup(self, server): - # The loader is in control of the instance, so we can - # hand it back to the test via a closure. - child = Child() - leaked.append(child) - server.add_plugin(child) + def on_install(self, server): + server.add_plugin(Child()) mcp = FastMCP("t", plugins=[Loader()]) + # `on_install` ran during construction; child is registered. + assert [p.meta.name for p in mcp.plugins] == ["loader", "child"] + + # Contributions installed once, persist across multiple cycles. + async with Client(mcp) as c: + await c.ping() async with Client(mcp) as c: await c.ping() - # Ephemeral cleanup ran — child is no longer in the plugin list, - # and its middleware is gone. - assert [p.meta.name for p in mcp.plugins] == ["loader"] - child_instance = leaked[0] - assert child_instance._fastmcp_ephemeral is True - - # User re-registers the same instance as a permanent plugin. - mcp.add_plugin(child_instance) - assert child_instance._fastmcp_ephemeral is False - - async with Client(mcp) as c: - await c.ping() - - # After a second cycle, the permanent registration survives and - # its middleware is installed exactly once. - assert child_instance in mcp.plugins tags = [m.tag for m in mcp.middleware if isinstance(m, _TraceMiddleware)] assert tags == ["child"] - async def test_loader_plugins_do_not_accumulate_across_cycles(self): - """Loader-added (ephemeral) plugins and their contributions are removed on teardown. - - Without this, a loader that adds children in setup() causes the - plugin list — and every contribution those children install — to - grow on every lifespan cycle. - """ - - class Child(Plugin): - meta = PluginMeta(name="child", version="0.1.0") - - def middleware(self): - return [_TraceMiddleware("child")] - - class Loader(Plugin): - meta = PluginMeta(name="loader", version="0.1.0") - - async def setup(self, server): - server.add_plugin(Child()) - - mcp = FastMCP("t", plugins=[Loader()]) - baseline_middleware = list(mcp.middleware) - - async with Client(mcp) as c: - await c.ping() - async with Client(mcp) as c: - await c.ping() - async with Client(mcp) as c: - await c.ping() - - # After three cycles: the loader remains, the ephemeral child has - # been removed, and the middleware it installed was reversed out - # each time so nothing has accumulated. - assert [p.meta.name for p in mcp.plugins] == ["loader"] - assert mcp.middleware == baseline_middleware - class TestRunHook: """Plugins that override `run()` directly (the long-running pattern).""" async def test_run_override_wraps_server_lifetime(self): """A plugin overriding run() sees the server live between setup and teardown.""" - from contextlib import asynccontextmanager recorder = _Recorder() @@ -1397,7 +1225,6 @@ class TestRunHook: async def test_run_override_can_use_async_with(self): """A plugin's run() can acquire an async-context resource and release it on exit.""" - from contextlib import asynccontextmanager recorder = _Recorder() @@ -1432,7 +1259,6 @@ class TestRunHook: async def test_run_override_cancellation_propagates_into_background_task(self): """A long-running background task inside run() is cancelled on shutdown.""" - from contextlib import asynccontextmanager recorder = _Recorder() @@ -1464,7 +1290,6 @@ class TestRunHook: async def test_run_override_raising_before_yield_aborts_startup(self): """If a plugin's run() raises before yielding, startup fails cleanly.""" - from contextlib import asynccontextmanager class BadStart(Plugin): meta = PluginMeta(name="bad-start", version="0.1.0") @@ -1481,7 +1306,6 @@ class TestRunHook: async def test_run_override_composes_with_simple_setup_teardown_plugins(self): """A server can mix run-override plugins with setup/teardown plugins.""" - from contextlib import asynccontextmanager recorder = _Recorder() @@ -1732,6 +1556,28 @@ class TestPluginCapabilities: """Plugin with no override contributes nothing.""" assert _TestPlugin().capabilities() == {} + async def test_capabilities_are_collected_at_registration_time(self): + """Capability hooks are sync install-time contributions, not + lifespan-time callbacks.""" + calls = 0 + + class P(_TestPlugin): + def capabilities(self): + nonlocal calls + calls += 1 + return {"experimental": {"registered": {}}} + + mcp = FastMCP("t", plugins=[P()]) + assert calls == 1 + + async with Client(mcp) as c: + result = c.initialize_result + assert result is not None + experimental = result.capabilities.experimental or {} + assert experimental.get("registered") == {} + + assert calls == 1 + async def test_experimental_contribution_reaches_initialize_response(self): """An experimental capability entry flows through to the client.""" @@ -1862,15 +1708,16 @@ class TestPluginCapabilities: # A's cached dict must not have been mutated to contain B's entry. assert a._caps == {"experimental": {"alpha": {}}} - async def test_loader_added_plugin_capabilities_contribute(self): - """Plugins added via the loader pattern still contribute capabilities.""" + async def test_on_install_loader_plugin_capabilities_contribute(self): + """Plugins added via the on_install loader pattern contribute + capabilities just like plugins passed to `plugins=[...]`.""" class Loaded(_TestPlugin): def capabilities(self): return {"experimental": {"loaded": {}}} class Loader(_TestPlugin): - async def setup(self, server): + def on_install(self, server): server.add_plugin(Loaded()) mcp = FastMCP("t", plugins=[Loader()]) @@ -1880,3 +1727,30 @@ class TestPluginCapabilities: assert result is not None experimental = result.capabilities.experimental or {} assert experimental.get("loaded") == {} + + async def test_on_install_loader_capabilities_follow_plugin_order(self): + """Capabilities merge in plugin-list order even though child + plugins install while the parent's `on_install()` hook is running.""" + + class Loaded(_TestPlugin): + def capabilities(self): + return {"experimental": {"shared": {"owner": "child"}}} + + class Loader(_TestPlugin): + def on_install(self, server): + server.add_plugin(Loaded()) + + def capabilities(self): + return {"experimental": {"shared": {"owner": "loader"}}} + + mcp = FastMCP("t", plugins=[Loader()]) + assert [type(plugin).__name__ for plugin in mcp.plugins] == [ + "Loader", + "Loaded", + ] + + async with Client(mcp) as c: + result = c.initialize_result + assert result is not None + experimental = result.capabilities.experimental or {} + assert experimental.get("shared") == {"owner": "child"} From c945f3307b33cfbad77f3337d9cb7fdb04333627 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 4 May 2026 12:23:20 -0400 Subject: [PATCH 15/17] Add first-party auth plugins --- docs/docs.json | 1 + docs/servers/auth/plugins.mdx | 85 ++ src/fastmcp/server/plugins/auth/__init__.py | 67 ++ src/fastmcp/server/plugins/auth/providers.py | 774 +++++++++++++++++++ src/fastmcp/server/plugins/auth/supabase.py | 5 + tests/server/plugins/test_auth_plugins.py | 331 ++++++++ 6 files changed, 1263 insertions(+) create mode 100644 docs/servers/auth/plugins.mdx create mode 100644 src/fastmcp/server/plugins/auth/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/providers.py create mode 100644 src/fastmcp/server/plugins/auth/supabase.py create mode 100644 tests/server/plugins/test_auth_plugins.py diff --git a/docs/docs.json b/docs/docs.json index 86798bbcd..3ac29ae38 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -172,6 +172,7 @@ "icon": "key", "pages": [ "servers/auth/authentication", + "servers/auth/plugins", "servers/auth/token-verification", "servers/auth/remote-oauth", "servers/auth/oauth-proxy", diff --git a/docs/servers/auth/plugins.mdx b/docs/servers/auth/plugins.mdx new file mode 100644 index 000000000..0bd0f2c52 --- /dev/null +++ b/docs/servers/auth/plugins.mdx @@ -0,0 +1,85 @@ +--- +title: Auth Plugins +description: Configure FastMCP authentication with first-party plugins. +icon: puzzle-piece +--- + +Auth plugins are the plugin-system entry point for FastMCP's built-in auth integrations. They wrap the existing auth providers and contribute exactly one provider through `Plugin.auth()`, so the server behavior is the same as passing `auth=...` directly. + +Use an auth plugin when you want authentication to be configured alongside other plugins, especially in declarative environments such as Horizon or `plugins.json`-style loaders. + +```python server.py +from fastmcp import FastMCP +from fastmcp.server.plugins.auth import GitHubAuth + +mcp = FastMCP( + "GitHub Protected Server", + plugins=[ + GitHubAuth( + { + "client_id": "your-github-client-id", + "client_secret": "your-github-client-secret", + "base_url": "https://your-server.com", + } + ) + ], +) +``` + +The provider APIs remain available and are still the most direct option in Python code: + +```python +from fastmcp import FastMCP +from fastmcp.server.auth.providers.github import GitHubProvider + +auth = GitHubProvider( + client_id="your-github-client-id", + client_secret="your-github-client-secret", + base_url="https://your-server.com", +) + +mcp = FastMCP("GitHub Protected Server", auth=auth) +``` + +## Included Plugins + +Import first-party auth plugins from `fastmcp.server.plugins.auth`: + +```python +from fastmcp.server.plugins.auth import ( + Auth0Auth, + AuthKitAuth, + AWSCognitoAuth, + AzureAuth, + ClerkAuth, + DescopeAuth, + DiscordAuth, + GitHubAuth, + GoogleAuth, + KeycloakAuth, + OCIAuth, + PropelAuth, + ScalekitAuth, + SupabaseAuth, + WorkOSAuth, +) +``` + +Each plugin accepts a matching `*AuthConfig` model or a plain dictionary. Config fields mirror the wrapped provider's constructor wherever the value can be represented as JSON. Python-only objects such as custom token verifiers, HTTP clients, and client storage are passed as constructor keyword arguments: + +```python +from fastmcp.server.plugins.auth import SupabaseAuth, SupabaseAuthConfig + +auth_plugin = SupabaseAuth( + SupabaseAuthConfig( + project_url="https://abc123.supabase.co", + base_url="https://your-server.com", + required_scopes=["read"], + ), + token_verifier=custom_verifier, +) + +mcp = FastMCP("Supabase Protected Server", plugins=[auth_plugin]) +``` + +Only one auth provider can be configured for a server. If a server already has `auth=...`, or if multiple plugins contribute auth, FastMCP raises during plugin installation. diff --git a/src/fastmcp/server/plugins/auth/__init__.py b/src/fastmcp/server/plugins/auth/__init__.py new file mode 100644 index 000000000..f78bb69f2 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/__init__.py @@ -0,0 +1,67 @@ +"""Auth plugins for FastMCP.""" + +from fastmcp.server.plugins.auth.providers import ( + Auth0Auth, + Auth0AuthConfig, + AuthKitAuth, + AuthKitAuthConfig, + AWSCognitoAuth, + AWSCognitoAuthConfig, + AzureAuth, + AzureAuthConfig, + ClerkAuth, + ClerkAuthConfig, + DescopeAuth, + DescopeAuthConfig, + DiscordAuth, + DiscordAuthConfig, + GitHubAuth, + GitHubAuthConfig, + GoogleAuth, + GoogleAuthConfig, + KeycloakAuth, + KeycloakAuthConfig, + OCIAuth, + OCIAuthConfig, + PropelAuth, + PropelAuthConfig, + ScalekitAuth, + ScalekitAuthConfig, + SupabaseAuth, + SupabaseAuthConfig, + WorkOSAuth, + WorkOSAuthConfig, +) + +__all__ = [ + "AWSCognitoAuth", + "AWSCognitoAuthConfig", + "Auth0Auth", + "Auth0AuthConfig", + "AuthKitAuth", + "AuthKitAuthConfig", + "AzureAuth", + "AzureAuthConfig", + "ClerkAuth", + "ClerkAuthConfig", + "DescopeAuth", + "DescopeAuthConfig", + "DiscordAuth", + "DiscordAuthConfig", + "GitHubAuth", + "GitHubAuthConfig", + "GoogleAuth", + "GoogleAuthConfig", + "KeycloakAuth", + "KeycloakAuthConfig", + "OCIAuth", + "OCIAuthConfig", + "PropelAuth", + "PropelAuthConfig", + "ScalekitAuth", + "ScalekitAuthConfig", + "SupabaseAuth", + "SupabaseAuthConfig", + "WorkOSAuth", + "WorkOSAuthConfig", +] diff --git a/src/fastmcp/server/plugins/auth/providers.py b/src/fastmcp/server/plugins/auth/providers.py new file mode 100644 index 000000000..fe5bc9848 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/providers.py @@ -0,0 +1,774 @@ +"""First-party auth plugins. + +These plugins are thin, JSON-configurable wrappers around FastMCP's +existing auth providers. Python-only dependencies such as HTTP clients, +token verifiers, and client storage stay as constructor arguments. +""" + +from __future__ import annotations + +from typing import Any, Generic, Literal, TypeVar + +import httpx +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl, BaseModel, ConfigDict + +from fastmcp.server.auth import AuthProvider, TokenVerifier +from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.auth.providers.aws import AWSCognitoProvider +from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.auth.providers.clerk import ClerkProvider +from fastmcp.server.auth.providers.descope import DescopeProvider +from fastmcp.server.auth.providers.discord import DiscordProvider +from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider +from fastmcp.server.auth.providers.oci import OCIProvider +from fastmcp.server.auth.providers.propelauth import ( + PropelAuthProvider, + PropelAuthTokenIntrospectionOverrides, +) +from fastmcp.server.auth.providers.scalekit import ScalekitProvider +from fastmcp.server.auth.providers.supabase import SupabaseProvider +from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSProvider +from fastmcp.server.plugins.base import Plugin, PluginMeta + +ConsentMode = bool | Literal["remember", "external"] +Algorithm = Literal["RS256", "ES256"] +ConfigT = TypeVar("ConfigT", bound=BaseModel) + + +class _AuthPlugin(Plugin[ConfigT], Generic[ConfigT]): + def _require(self, *fields: str) -> None: + missing = [field for field in fields if getattr(self.config, field) is None] + if missing: + names = ", ".join(f"`{field}`" for field in missing) + raise ValueError(f"{type(self).__name__} requires {names}.") + + def _require_one(self, *fields: str) -> None: + if not any(getattr(self.config, field) is not None for field in fields): + names = " or ".join(f"`{field}`" for field in fields) + raise ValueError(f"{type(self).__name__} requires {names}.") + + def _kwargs(self, *fields: str) -> dict[str, Any]: + return { + field: getattr(self.config, field) + for field in fields + if getattr(self.config, field) is not None + } + + +class _PluginConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class _OAuthProxyConfig(_PluginConfig): + base_url: AnyHttpUrl | str | None = None + resource_base_url: AnyHttpUrl | str | None = None + issuer_url: AnyHttpUrl | str | None = None + redirect_path: str | None = None + required_scopes: list[str] | None = None + allowed_client_redirect_uris: list[str] | None = None + jwt_signing_key: str | None = None + require_authorization_consent: ConsentMode = True + consent_csp_policy: str | None = None + forward_resource: bool = True + + +class _OAuthProviderConfig(_OAuthProxyConfig): + client_id: str | None = None + client_secret: str | None = None + timeout_seconds: int = 10 + enable_cimd: bool = True + + +class _RemoteAuthConfig(_PluginConfig): + base_url: AnyHttpUrl | str | None = None + required_scopes: list[str] | None = None + scopes_supported: list[str] | None = None + resource_name: str | None = None + resource_documentation: AnyHttpUrl | None = None + + +class Auth0AuthConfig(_OAuthProxyConfig): + """Config model for the Auth0 auth plugin.""" + + config_url: AnyHttpUrl | str | None = None + client_id: str | None = None + client_secret: str | None = None + audience: str | None = None + + +class Auth0Auth(_AuthPlugin[Auth0AuthConfig]): + """Contribute an `Auth0Provider` as the server's auth provider.""" + + meta = PluginMeta(name="auth0-auth") + + def __init__( + self, + config: Auth0AuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + + def auth(self) -> AuthProvider | None: + self._require("config_url", "client_id", "client_secret", "audience", "base_url") + return Auth0Provider( + **self._kwargs( + "config_url", + "client_id", + "client_secret", + "audience", + "base_url", + "resource_base_url", + "issuer_url", + "required_scopes", + "redirect_path", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + ), + client_storage=self._client_storage, + ) + + +class AuthKitAuthConfig(_RemoteAuthConfig): + """Config model for the WorkOS AuthKit auth plugin.""" + + authkit_domain: AnyHttpUrl | str | None = None + resource_base_url: AnyHttpUrl | str | None = None + + +class AuthKitAuth(_AuthPlugin[AuthKitAuthConfig]): + """Contribute an `AuthKitProvider` as the server's auth provider.""" + + meta = PluginMeta(name="authkit-auth") + + def __init__( + self, + config: AuthKitAuthConfig | dict[str, Any] | None = None, + *, + token_verifier: TokenVerifier | None = None, + ) -> None: + super().__init__(config) + self._token_verifier = token_verifier + + def auth(self) -> AuthProvider | None: + self._require("authkit_domain", "base_url") + return AuthKitProvider( + **self._kwargs( + "authkit_domain", + "base_url", + "resource_base_url", + "required_scopes", + "scopes_supported", + "resource_name", + "resource_documentation", + ), + token_verifier=self._token_verifier, + ) + + +class AWSCognitoAuthConfig(_OAuthProxyConfig): + """Config model for the AWS Cognito auth plugin.""" + + user_pool_id: str | None = None + client_id: str | None = None + client_secret: str | None = None + aws_region: str = "eu-central-1" + redirect_path: str | None = "/auth/callback" + + +class AWSCognitoAuth(_AuthPlugin[AWSCognitoAuthConfig]): + """Contribute an `AWSCognitoProvider` as the server's auth provider.""" + + meta = PluginMeta(name="aws-cognito-auth") + + def __init__( + self, + config: AWSCognitoAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + + def auth(self) -> AuthProvider | None: + self._require("user_pool_id", "client_id", "client_secret", "base_url") + return AWSCognitoProvider( + **self._kwargs( + "user_pool_id", + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "aws_region", + "issuer_url", + "redirect_path", + "required_scopes", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + ), + client_storage=self._client_storage, + ) + + +class AzureAuthConfig(_OAuthProviderConfig): + """Config model for the Azure auth plugin.""" + + tenant_id: str | None = None + required_scopes: list[str] | None = None + identifier_uri: str | None = None + additional_authorize_scopes: list[str] | None = None + base_authority: str = "login.microsoftonline.com" + + +class AzureAuth(_AuthPlugin[AzureAuthConfig]): + """Contribute an `AzureProvider` as the server's auth provider.""" + + meta = PluginMeta(name="azure-auth") + + def __init__( + self, + config: AzureAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("client_id", "tenant_id", "required_scopes", "base_url") + self._require_one("client_secret", "jwt_signing_key") + return AzureProvider( + **self._kwargs( + "client_id", + "client_secret", + "tenant_id", + "required_scopes", + "base_url", + "resource_base_url", + "identifier_uri", + "issuer_url", + "redirect_path", + "additional_authorize_scopes", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "base_authority", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) + + +class ClerkAuthConfig(_OAuthProviderConfig): + """Config model for the Clerk auth plugin.""" + + domain: str | None = None + valid_scopes: list[str] | None = None + extra_authorize_params: dict[str, str] | None = None + + +class ClerkAuth(_AuthPlugin[ClerkAuthConfig]): + """Contribute a `ClerkProvider` as the server's auth provider.""" + + meta = PluginMeta(name="clerk-auth") + + def __init__( + self, + config: ClerkAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("domain", "client_id", "base_url") + self._require_one("client_secret", "jwt_signing_key") + return ClerkProvider( + **self._kwargs( + "domain", + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "issuer_url", + "redirect_path", + "required_scopes", + "valid_scopes", + "timeout_seconds", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "extra_authorize_params", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) + + +class DescopeAuthConfig(_RemoteAuthConfig): + """Config model for the Descope auth plugin.""" + + config_url: AnyHttpUrl | str | None = None + project_id: str | None = None + descope_base_url: AnyHttpUrl | str | None = None + + +class DescopeAuth(_AuthPlugin[DescopeAuthConfig]): + """Contribute a `DescopeProvider` as the server's auth provider.""" + + meta = PluginMeta(name="descope-auth") + + def __init__( + self, + config: DescopeAuthConfig | dict[str, Any] | None = None, + *, + token_verifier: TokenVerifier | None = None, + ) -> None: + super().__init__(config) + self._token_verifier = token_verifier + + def auth(self) -> AuthProvider | None: + self._require("base_url") + if self.config.config_url is None: + self._require("project_id", "descope_base_url") + return DescopeProvider( + **self._kwargs( + "base_url", + "config_url", + "project_id", + "descope_base_url", + "required_scopes", + "scopes_supported", + "resource_name", + "resource_documentation", + ), + token_verifier=self._token_verifier, + ) + + +class DiscordAuthConfig(_OAuthProviderConfig): + """Config model for the Discord auth plugin.""" + + +class DiscordAuth(_AuthPlugin[DiscordAuthConfig]): + """Contribute a `DiscordProvider` as the server's auth provider.""" + + meta = PluginMeta(name="discord-auth") + + def __init__( + self, + config: DiscordAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("client_id", "client_secret", "base_url") + return DiscordProvider( + **self._kwargs( + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "issuer_url", + "redirect_path", + "required_scopes", + "timeout_seconds", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) + + +class GitHubAuthConfig(_OAuthProviderConfig): + """Config model for the GitHub auth plugin.""" + + cache_ttl_seconds: int | None = None + max_cache_size: int | None = None + + +class GitHubAuth(_AuthPlugin[GitHubAuthConfig]): + """Contribute a `GitHubProvider` as the server's auth provider.""" + + meta = PluginMeta(name="github-auth") + + def __init__( + self, + config: GitHubAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("client_id", "client_secret", "base_url") + return GitHubProvider( + **self._kwargs( + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "issuer_url", + "redirect_path", + "required_scopes", + "timeout_seconds", + "cache_ttl_seconds", + "max_cache_size", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) + + +class GoogleAuthConfig(_OAuthProviderConfig): + """Config model for the Google auth plugin.""" + + valid_scopes: list[str] | None = None + extra_authorize_params: dict[str, str] | None = None + + +class GoogleAuth(_AuthPlugin[GoogleAuthConfig]): + """Contribute a `GoogleProvider` as the server's auth provider.""" + + meta = PluginMeta(name="google-auth") + + def __init__( + self, + config: GoogleAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("client_id", "base_url") + self._require_one("client_secret", "jwt_signing_key") + return GoogleProvider( + **self._kwargs( + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "issuer_url", + "redirect_path", + "required_scopes", + "valid_scopes", + "timeout_seconds", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "extra_authorize_params", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) + + +class KeycloakAuthConfig(_PluginConfig): + """Config model for the Keycloak auth plugin.""" + + realm_url: AnyHttpUrl | str | None = None + base_url: AnyHttpUrl | str | None = None + required_scopes: list[str] | str | None = None + audience: str | list[str] | None = None + + +class KeycloakAuth(_AuthPlugin[KeycloakAuthConfig]): + """Contribute a `KeycloakAuthProvider` as the server's auth provider.""" + + meta = PluginMeta(name="keycloak-auth") + + def __init__( + self, + config: KeycloakAuthConfig | dict[str, Any] | None = None, + *, + token_verifier: TokenVerifier | None = None, + ) -> None: + super().__init__(config) + self._token_verifier = token_verifier + + def auth(self) -> AuthProvider | None: + self._require("realm_url", "base_url") + return KeycloakAuthProvider( + **self._kwargs("realm_url", "base_url", "required_scopes", "audience"), + token_verifier=self._token_verifier, + ) + + +class OCIAuthConfig(_OAuthProxyConfig): + """Config model for the OCI auth plugin.""" + + config_url: AnyHttpUrl | str | None = None + client_id: str | None = None + client_secret: str | None = None + audience: str | None = None + + +class OCIAuth(_AuthPlugin[OCIAuthConfig]): + """Contribute an `OCIProvider` as the server's auth provider.""" + + meta = PluginMeta(name="oci-auth") + + def __init__( + self, + config: OCIAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + + def auth(self) -> AuthProvider | None: + self._require("config_url", "client_id", "client_secret", "base_url") + return OCIProvider( + **self._kwargs( + "config_url", + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "audience", + "issuer_url", + "required_scopes", + "redirect_path", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + ), + client_storage=self._client_storage, + ) + + +class PropelAuthConfig(_RemoteAuthConfig): + """Config model for the PropelAuth auth plugin.""" + + auth_url: AnyHttpUrl | str | None = None + introspection_client_id: str | None = None + introspection_client_secret: str | None = None + resource: AnyHttpUrl | str | None = None + introspection_timeout_seconds: int | None = None + introspection_cache_ttl_seconds: int | None = None + introspection_max_cache_size: int | None = None + + +class PropelAuth(_AuthPlugin[PropelAuthConfig]): + """Contribute a `PropelAuthProvider` as the server's auth provider.""" + + meta = PluginMeta(name="propelauth-auth") + + def __init__( + self, + config: PropelAuthConfig | dict[str, Any] | None = None, + *, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require( + "auth_url", + "introspection_client_id", + "introspection_client_secret", + "base_url", + ) + overrides: PropelAuthTokenIntrospectionOverrides = {} + if self.config.introspection_timeout_seconds is not None: + overrides["timeout_seconds"] = self.config.introspection_timeout_seconds + if self.config.introspection_cache_ttl_seconds is not None: + overrides["cache_ttl_seconds"] = self.config.introspection_cache_ttl_seconds + if self.config.introspection_max_cache_size is not None: + overrides["max_cache_size"] = self.config.introspection_max_cache_size + if self._http_client is not None: + overrides["http_client"] = self._http_client + + return PropelAuthProvider( + **self._kwargs( + "auth_url", + "introspection_client_id", + "introspection_client_secret", + "base_url", + "required_scopes", + "scopes_supported", + "resource_name", + "resource_documentation", + "resource", + ), + token_introspection_overrides=overrides or None, + ) + + +class ScalekitAuthConfig(_RemoteAuthConfig): + """Config model for the Scalekit auth plugin.""" + + environment_url: AnyHttpUrl | str | None = None + resource_id: str | None = None + mcp_url: AnyHttpUrl | str | None = None + client_id: str | None = None + + +class ScalekitAuth(_AuthPlugin[ScalekitAuthConfig]): + """Contribute a `ScalekitProvider` as the server's auth provider.""" + + meta = PluginMeta(name="scalekit-auth") + + def __init__( + self, + config: ScalekitAuthConfig | dict[str, Any] | None = None, + *, + token_verifier: TokenVerifier | None = None, + ) -> None: + super().__init__(config) + self._token_verifier = token_verifier + + def auth(self) -> AuthProvider | None: + self._require("environment_url", "resource_id") + self._require_one("base_url", "mcp_url") + return ScalekitProvider( + **self._kwargs( + "environment_url", + "resource_id", + "base_url", + "mcp_url", + "client_id", + "required_scopes", + "scopes_supported", + "resource_name", + "resource_documentation", + ), + token_verifier=self._token_verifier, + ) + + +class SupabaseAuthConfig(_RemoteAuthConfig): + """Config model for the Supabase auth plugin.""" + + project_url: AnyHttpUrl | str | None = None + auth_route: str = "/auth/v1" + algorithm: Algorithm = "ES256" + + +class SupabaseAuth(_AuthPlugin[SupabaseAuthConfig]): + """Contribute a `SupabaseProvider` as the server's auth provider.""" + + meta = PluginMeta(name="supabase-auth") + + def __init__( + self, + config: SupabaseAuthConfig | dict[str, Any] | None = None, + *, + token_verifier: TokenVerifier | None = None, + ) -> None: + super().__init__(config) + self._token_verifier = token_verifier + + def auth(self) -> AuthProvider | None: + self._require("project_url", "base_url") + return SupabaseProvider( + **self._kwargs( + "project_url", + "base_url", + "auth_route", + "algorithm", + "required_scopes", + "scopes_supported", + "resource_name", + "resource_documentation", + ), + token_verifier=self._token_verifier, + ) + + +class WorkOSAuthConfig(_OAuthProviderConfig): + """Config model for the WorkOS auth plugin.""" + + authkit_domain: str | None = None + + +class WorkOSAuth(_AuthPlugin[WorkOSAuthConfig]): + """Contribute a `WorkOSProvider` as the server's auth provider.""" + + meta = PluginMeta(name="workos-auth") + + def __init__( + self, + config: WorkOSAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("client_id", "client_secret", "authkit_domain", "base_url") + return WorkOSProvider( + **self._kwargs( + "client_id", + "client_secret", + "authkit_domain", + "base_url", + "resource_base_url", + "issuer_url", + "redirect_path", + "required_scopes", + "timeout_seconds", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) diff --git a/src/fastmcp/server/plugins/auth/supabase.py b/src/fastmcp/server/plugins/auth/supabase.py new file mode 100644 index 000000000..debbcbac0 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/supabase.py @@ -0,0 +1,5 @@ +"""Supabase auth plugin.""" + +from fastmcp.server.plugins.auth.providers import SupabaseAuth, SupabaseAuthConfig + +__all__ = ["SupabaseAuth", "SupabaseAuthConfig"] diff --git a/tests/server/plugins/test_auth_plugins.py b/tests/server/plugins/test_auth_plugins.py new file mode 100644 index 000000000..ae724512f --- /dev/null +++ b/tests/server/plugins/test_auth_plugins.py @@ -0,0 +1,331 @@ +"""Tests for first-party auth plugin wrappers.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +import pytest +from pydantic import ValidationError + +from fastmcp import FastMCP +from fastmcp.server.auth.oidc_proxy import OIDCConfiguration +from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.auth.providers.aws import AWSCognitoProvider +from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.auth.providers.clerk import ClerkProvider +from fastmcp.server.auth.providers.descope import DescopeProvider +from fastmcp.server.auth.providers.discord import DiscordProvider +from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.auth.providers.jwt import StaticTokenVerifier +from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider +from fastmcp.server.auth.providers.oci import OCIProvider +from fastmcp.server.auth.providers.propelauth import PropelAuthProvider +from fastmcp.server.auth.providers.scalekit import ScalekitProvider +from fastmcp.server.auth.providers.supabase import SupabaseProvider +from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSProvider +from fastmcp.server.plugins.auth import ( + Auth0Auth, + Auth0AuthConfig, + AuthKitAuth, + AuthKitAuthConfig, + AWSCognitoAuth, + AWSCognitoAuthConfig, + AzureAuth, + AzureAuthConfig, + ClerkAuth, + ClerkAuthConfig, + DescopeAuth, + DescopeAuthConfig, + DiscordAuth, + DiscordAuthConfig, + GitHubAuth, + GitHubAuthConfig, + GoogleAuth, + GoogleAuthConfig, + KeycloakAuth, + KeycloakAuthConfig, + OCIAuth, + OCIAuthConfig, + PropelAuth, + PropelAuthConfig, + ScalekitAuth, + ScalekitAuthConfig, + SupabaseAuth, + SupabaseAuthConfig, + WorkOSAuth, + WorkOSAuthConfig, +) + + +def _verifier() -> StaticTokenVerifier: + return StaticTokenVerifier(tokens={"t": {"client_id": "c", "scopes": []}}) + + +def _oidc_config() -> OIDCConfiguration: + return OIDCConfiguration.model_validate( + { + "issuer": "https://idp.example.com", + "authorization_endpoint": "https://idp.example.com/authorize", + "token_endpoint": "https://idp.example.com/token", + "jwks_uri": "https://idp.example.com/jwks.json", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + } + ) + + +@pytest.fixture(autouse=True) +def _mock_oidc_discovery(): + with patch( + "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration", + return_value=_oidc_config(), + ): + yield + + +PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ + ( + Auth0Auth, + Auth0AuthConfig, + { + "config_url": "https://idp.example.com/.well-known/openid-configuration", + "client_id": "client", + "client_secret": "secret", + "audience": "audience", + "base_url": "https://mcp.example.com", + }, + Auth0Provider, + ), + ( + AuthKitAuth, + AuthKitAuthConfig, + { + "authkit_domain": "https://example.authkit.app", + "base_url": "https://mcp.example.com", + }, + AuthKitProvider, + ), + ( + AWSCognitoAuth, + AWSCognitoAuthConfig, + { + "user_pool_id": "us-east-1_abc", + "client_id": "client", + "client_secret": "secret", + "aws_region": "us-east-1", + "base_url": "https://mcp.example.com", + }, + AWSCognitoProvider, + ), + ( + AzureAuth, + AzureAuthConfig, + { + "client_id": "client", + "client_secret": "secret", + "tenant_id": "tenant", + "required_scopes": ["read"], + "base_url": "https://mcp.example.com", + }, + AzureProvider, + ), + ( + ClerkAuth, + ClerkAuthConfig, + { + "domain": "example.clerk.accounts.dev", + "client_id": "client", + "client_secret": "secret", + "base_url": "https://mcp.example.com", + }, + ClerkProvider, + ), + ( + DescopeAuth, + DescopeAuthConfig, + { + "config_url": "https://api.descope.com/v1/apps/agentic/P123/M456/.well-known/openid-configuration", + "base_url": "https://mcp.example.com", + }, + DescopeProvider, + ), + ( + DiscordAuth, + DiscordAuthConfig, + { + "client_id": "client", + "client_secret": "secret", + "base_url": "https://mcp.example.com", + }, + DiscordProvider, + ), + ( + GitHubAuth, + GitHubAuthConfig, + { + "client_id": "client", + "client_secret": "secret", + "base_url": "https://mcp.example.com", + }, + GitHubProvider, + ), + ( + GoogleAuth, + GoogleAuthConfig, + { + "client_id": "client", + "client_secret": "secret", + "base_url": "https://mcp.example.com", + }, + GoogleProvider, + ), + ( + KeycloakAuth, + KeycloakAuthConfig, + { + "realm_url": "https://keycloak.example.com/realms/main", + "base_url": "https://mcp.example.com", + }, + KeycloakAuthProvider, + ), + ( + OCIAuth, + OCIAuthConfig, + { + "config_url": "https://idp.example.com/.well-known/openid-configuration", + "client_id": "client", + "client_secret": "secret", + "base_url": "https://mcp.example.com", + }, + OCIProvider, + ), + ( + PropelAuth, + PropelAuthConfig, + { + "auth_url": "https://auth.example.com", + "introspection_client_id": "client", + "introspection_client_secret": "secret", + "base_url": "https://mcp.example.com", + }, + PropelAuthProvider, + ), + ( + ScalekitAuth, + ScalekitAuthConfig, + { + "environment_url": "https://env.scalekit.com", + "resource_id": "res_123", + "base_url": "https://mcp.example.com", + }, + ScalekitProvider, + ), + ( + SupabaseAuth, + SupabaseAuthConfig, + { + "project_url": "https://abc123.supabase.co", + "base_url": "https://mcp.example.com", + }, + SupabaseProvider, + ), + ( + WorkOSAuth, + WorkOSAuthConfig, + { + "client_id": "client", + "client_secret": "secret", + "authkit_domain": "https://example.authkit.app", + "base_url": "https://mcp.example.com", + }, + WorkOSProvider, + ), +] + + +def _plugin_kwargs(plugin_cls: type) -> dict[str, Any]: + if plugin_cls in {AuthKitAuth, DescopeAuth, KeycloakAuth, ScalekitAuth, SupabaseAuth}: + return {"token_verifier": _verifier()} + return {} + + +class TestAuthProviderPlugins: + @pytest.mark.parametrize( + ("plugin_cls", "config_cls", "config", "provider_cls"), PROVIDER_CASES + ) + def test_config_generic_binding(self, plugin_cls, config_cls, config, provider_cls): + assert plugin_cls._config_cls is config_cls + + @pytest.mark.parametrize( + ("plugin_cls", "config_cls", "config", "provider_cls"), PROVIDER_CASES + ) + def test_default_config_instantiable( + self, plugin_cls, config_cls, config, provider_cls + ): + assert config_cls() + + @pytest.mark.parametrize( + ("plugin_cls", "config_cls", "config", "provider_cls"), PROVIDER_CASES + ) + def test_unknown_config_key_rejected( + self, plugin_cls, config_cls, config, provider_cls + ): + with pytest.raises((ValidationError, Exception), match="forbid|extra"): + config_cls(not_a_real_option=True) + + @pytest.mark.parametrize( + ("plugin_cls", "config_cls", "config", "provider_cls"), PROVIDER_CASES + ) + def test_auth_builds_provider(self, plugin_cls, config_cls, config, provider_cls): + auth = plugin_cls(config, **_plugin_kwargs(plugin_cls)).auth() + + assert isinstance(auth, provider_cls) + + @pytest.mark.parametrize( + ("plugin_cls", "config_cls", "config", "provider_cls"), PROVIDER_CASES + ) + def test_plugin_installs_as_server_auth( + self, plugin_cls, config_cls, config, provider_cls + ): + plugin = plugin_cls(config, **_plugin_kwargs(plugin_cls)) + + mcp = FastMCP("t", plugins=[plugin]) + + assert isinstance(mcp.auth, provider_cls) + + @pytest.mark.parametrize("missing", ["project_url", "base_url"]) + def test_required_fields_checked_when_auth_builds(self, missing: str): + config = { + "project_url": "https://abc123.supabase.co", + "base_url": "https://mcp.example.com", + } + del config[missing] + + plugin = SupabaseAuth(config, token_verifier=_verifier()) + + with pytest.raises(ValueError, match=missing): + plugin.auth() + + def test_supabase_passthroughs_config_and_python_verifier(self): + verifier = _verifier() + plugin = SupabaseAuth( + SupabaseAuthConfig( + project_url="https://abc123.supabase.co", + base_url="https://mcp.example.com", + required_scopes=["read"], + scopes_supported=["read", "write"], + resource_name="Example MCP", + ), + token_verifier=verifier, + ) + + auth = plugin.auth() + + assert isinstance(auth, SupabaseProvider) + assert auth.token_verifier is verifier + assert str(auth.base_url).rstrip("/") == "https://mcp.example.com" + assert auth._scopes_supported == ["read", "write"] + assert auth.resource_name == "Example MCP" From 428427220f6bd58d090325b9d83d962ae3c07b70 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 4 May 2026 12:43:00 -0400 Subject: [PATCH 16/17] Avoid eager auth provider imports --- src/fastmcp/server/plugins/auth/providers.py | 54 +++++++++++++------- tests/server/plugins/test_auth_plugins.py | 8 ++- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/fastmcp/server/plugins/auth/providers.py b/src/fastmcp/server/plugins/auth/providers.py index fe5bc9848..d25edcfb4 100644 --- a/src/fastmcp/server/plugins/auth/providers.py +++ b/src/fastmcp/server/plugins/auth/providers.py @@ -14,23 +14,6 @@ from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, BaseModel, ConfigDict from fastmcp.server.auth import AuthProvider, TokenVerifier -from fastmcp.server.auth.providers.auth0 import Auth0Provider -from fastmcp.server.auth.providers.aws import AWSCognitoProvider -from fastmcp.server.auth.providers.azure import AzureProvider -from fastmcp.server.auth.providers.clerk import ClerkProvider -from fastmcp.server.auth.providers.descope import DescopeProvider -from fastmcp.server.auth.providers.discord import DiscordProvider -from fastmcp.server.auth.providers.github import GitHubProvider -from fastmcp.server.auth.providers.google import GoogleProvider -from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider -from fastmcp.server.auth.providers.oci import OCIProvider -from fastmcp.server.auth.providers.propelauth import ( - PropelAuthProvider, - PropelAuthTokenIntrospectionOverrides, -) -from fastmcp.server.auth.providers.scalekit import ScalekitProvider -from fastmcp.server.auth.providers.supabase import SupabaseProvider -from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSProvider from fastmcp.server.plugins.base import Plugin, PluginMeta ConsentMode = bool | Literal["remember", "external"] @@ -114,7 +97,11 @@ class Auth0Auth(_AuthPlugin[Auth0AuthConfig]): self._client_storage = client_storage def auth(self) -> AuthProvider | None: - self._require("config_url", "client_id", "client_secret", "audience", "base_url") + from fastmcp.server.auth.providers.auth0 import Auth0Provider + + self._require( + "config_url", "client_id", "client_secret", "audience", "base_url" + ) return Auth0Provider( **self._kwargs( "config_url", @@ -158,6 +145,8 @@ class AuthKitAuth(_AuthPlugin[AuthKitAuthConfig]): self._token_verifier = token_verifier def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.workos import AuthKitProvider + self._require("authkit_domain", "base_url") return AuthKitProvider( **self._kwargs( @@ -198,6 +187,8 @@ class AWSCognitoAuth(_AuthPlugin[AWSCognitoAuthConfig]): self._client_storage = client_storage def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.aws import AWSCognitoProvider + self._require("user_pool_id", "client_id", "client_secret", "base_url") return AWSCognitoProvider( **self._kwargs( @@ -247,6 +238,8 @@ class AzureAuth(_AuthPlugin[AzureAuthConfig]): self._http_client = http_client def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.azure import AzureProvider + self._require("client_id", "tenant_id", "required_scopes", "base_url") self._require_one("client_secret", "jwt_signing_key") return AzureProvider( @@ -299,6 +292,8 @@ class ClerkAuth(_AuthPlugin[ClerkAuthConfig]): self._http_client = http_client def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.clerk import ClerkProvider + self._require("domain", "client_id", "base_url") self._require_one("client_secret", "jwt_signing_key") return ClerkProvider( @@ -349,6 +344,8 @@ class DescopeAuth(_AuthPlugin[DescopeAuthConfig]): self._token_verifier = token_verifier def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.descope import DescopeProvider + self._require("base_url") if self.config.config_url is None: self._require("project_id", "descope_base_url") @@ -388,6 +385,8 @@ class DiscordAuth(_AuthPlugin[DiscordAuthConfig]): self._http_client = http_client def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.discord import DiscordProvider + self._require("client_id", "client_secret", "base_url") return DiscordProvider( **self._kwargs( @@ -435,6 +434,8 @@ class GitHubAuth(_AuthPlugin[GitHubAuthConfig]): self._http_client = http_client def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.github import GitHubProvider + self._require("client_id", "client_secret", "base_url") return GitHubProvider( **self._kwargs( @@ -484,6 +485,8 @@ class GoogleAuth(_AuthPlugin[GoogleAuthConfig]): self._http_client = http_client def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.google import GoogleProvider + self._require("client_id", "base_url") self._require_one("client_secret", "jwt_signing_key") return GoogleProvider( @@ -534,6 +537,8 @@ class KeycloakAuth(_AuthPlugin[KeycloakAuthConfig]): self._token_verifier = token_verifier def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider + self._require("realm_url", "base_url") return KeycloakAuthProvider( **self._kwargs("realm_url", "base_url", "required_scopes", "audience"), @@ -565,6 +570,8 @@ class OCIAuth(_AuthPlugin[OCIAuthConfig]): self._client_storage = client_storage def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.oci import OCIProvider + self._require("config_url", "client_id", "client_secret", "base_url") return OCIProvider( **self._kwargs( @@ -614,6 +621,11 @@ class PropelAuth(_AuthPlugin[PropelAuthConfig]): self._http_client = http_client def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.propelauth import ( + PropelAuthProvider, + PropelAuthTokenIntrospectionOverrides, + ) + self._require( "auth_url", "introspection_client_id", @@ -670,6 +682,8 @@ class ScalekitAuth(_AuthPlugin[ScalekitAuthConfig]): self._token_verifier = token_verifier def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.scalekit import ScalekitProvider + self._require("environment_url", "resource_id") self._require_one("base_url", "mcp_url") return ScalekitProvider( @@ -711,6 +725,8 @@ class SupabaseAuth(_AuthPlugin[SupabaseAuthConfig]): self._token_verifier = token_verifier def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.supabase import SupabaseProvider + self._require("project_url", "base_url") return SupabaseProvider( **self._kwargs( @@ -750,6 +766,8 @@ class WorkOSAuth(_AuthPlugin[WorkOSAuthConfig]): self._http_client = http_client def auth(self) -> AuthProvider | None: + from fastmcp.server.auth.providers.workos import WorkOSProvider + self._require("client_id", "client_secret", "authkit_domain", "base_url") return WorkOSProvider( **self._kwargs( diff --git a/tests/server/plugins/test_auth_plugins.py b/tests/server/plugins/test_auth_plugins.py index ae724512f..d208421fa 100644 --- a/tests/server/plugins/test_auth_plugins.py +++ b/tests/server/plugins/test_auth_plugins.py @@ -247,7 +247,13 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ def _plugin_kwargs(plugin_cls: type) -> dict[str, Any]: - if plugin_cls in {AuthKitAuth, DescopeAuth, KeycloakAuth, ScalekitAuth, SupabaseAuth}: + if plugin_cls in { + AuthKitAuth, + DescopeAuth, + KeycloakAuth, + ScalekitAuth, + SupabaseAuth, + }: return {"token_verifier": _verifier()} return {} From 835ba07bcd7c26271d049c0d725effd548290402 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 10 May 2026 09:13:02 -0400 Subject: [PATCH 17/17] WIP auth provider plugins checkpoint --- docs/deployment/http.mdx | 4 +- docs/development/v3-notes/v3-features.mdx | 2 +- .../upgrading/from-fastmcp-2.mdx | 2 +- docs/integrations/auth0.mdx | 48 +- docs/integrations/authkit.mdx | 28 +- docs/integrations/aws-cognito.mdx | 48 +- docs/integrations/azure.mdx | 125 +-- docs/integrations/descope.mdx | 30 +- docs/integrations/discord.mdx | 45 +- docs/integrations/github.mdx | 42 +- docs/integrations/google.mdx | 54 +- docs/integrations/keycloak.mdx | 26 +- docs/integrations/oci.mdx | 24 +- docs/integrations/propelauth.mdx | 68 +- docs/integrations/scalekit.mdx | 34 +- docs/integrations/supabase.mdx | 28 +- docs/integrations/workos.mdx | 44 +- docs/servers/auth/authentication.mdx | 6 +- docs/servers/auth/oauth-proxy.mdx | 4 +- docs/servers/auth/oidc-proxy.mdx | 4 +- docs/servers/auth/plugins.mdx | 67 +- docs/servers/auth/remote-oauth.mdx | 2 +- docs/servers/storage-backends.mdx | 6 +- docs/v2/deployment/http.mdx | 4 +- docs/v2/integrations/auth0.mdx | 8 +- docs/v2/integrations/authkit.mdx | 6 +- docs/v2/integrations/aws-cognito.mdx | 8 +- docs/v2/integrations/azure.mdx | 8 +- docs/v2/integrations/descope.mdx | 6 +- docs/v2/integrations/discord.mdx | 8 +- docs/v2/integrations/github.mdx | 8 +- docs/v2/integrations/google.mdx | 8 +- docs/v2/integrations/oci.mdx | 6 +- docs/v2/integrations/scalekit.mdx | 6 +- docs/v2/integrations/supabase.mdx | 6 +- docs/v2/integrations/workos.mdx | 12 +- docs/v2/servers/auth/authentication.mdx | 16 +- docs/v2/servers/auth/oauth-proxy.mdx | 4 +- docs/v2/servers/auth/oidc-proxy.mdx | 4 +- docs/v2/servers/auth/remote-oauth.mdx | 2 +- docs/v2/servers/storage-backends.mdx | 8 +- src/fastmcp/server/auth/providers/auth0.py | 143 +--- src/fastmcp/server/auth/providers/aws.py | 238 +----- src/fastmcp/server/auth/providers/azure.py | 786 +---------------- src/fastmcp/server/auth/providers/clerk.py | 397 +-------- src/fastmcp/server/auth/providers/descope.py | 215 +---- src/fastmcp/server/auth/providers/discord.py | 297 +------ src/fastmcp/server/auth/providers/github.py | 312 +------ src/fastmcp/server/auth/providers/google.py | 374 +-------- src/fastmcp/server/auth/providers/keycloak.py | 82 +- src/fastmcp/server/auth/providers/oci.py | 186 +--- .../server/auth/providers/propelauth.py | 243 +----- src/fastmcp/server/auth/providers/scalekit.py | 218 +---- src/fastmcp/server/auth/providers/supabase.py | 187 +---- src/fastmcp/server/auth/providers/workos.py | 439 +--------- src/fastmcp/server/plugins/auth/__init__.py | 68 +- src/fastmcp/server/plugins/auth/_base.py | 65 ++ .../server/plugins/auth/auth0/__init__.py | 5 + .../server/plugins/auth/auth0/plugin.py | 63 ++ .../server/plugins/auth/auth0/provider.py | 135 +++ .../server/plugins/auth/authkit/__init__.py | 5 + .../server/plugins/auth/authkit/plugin.py | 51 ++ .../server/plugins/auth/authkit/provider.py | 186 ++++ .../server/plugins/auth/aws/__init__.py | 5 + src/fastmcp/server/plugins/auth/aws/plugin.py | 61 ++ .../server/plugins/auth/aws/provider.py | 229 +++++ .../server/plugins/auth/azure/__init__.py | 5 + .../server/plugins/auth/azure/plugin.py | 69 ++ .../server/plugins/auth/azure/provider.py | 768 +++++++++++++++++ .../server/plugins/auth/clerk/__init__.py | 5 + .../server/plugins/auth/clerk/plugin.py | 67 ++ .../server/plugins/auth/clerk/provider.py | 388 +++++++++ .../server/plugins/auth/descope/__init__.py | 5 + .../server/plugins/auth/descope/plugin.py | 55 ++ .../server/plugins/auth/descope/provider.py | 209 +++++ .../server/plugins/auth/discord/__init__.py | 5 + .../server/plugins/auth/discord/plugin.py | 59 ++ .../server/plugins/auth/discord/provider.py | 288 +++++++ .../server/plugins/auth/github/__init__.py | 5 + .../server/plugins/auth/github/plugin.py | 64 ++ .../server/plugins/auth/github/provider.py | 303 +++++++ .../server/plugins/auth/google/__init__.py | 5 + .../server/plugins/auth/google/plugin.py | 65 ++ .../server/plugins/auth/google/provider.py | 365 ++++++++ .../server/plugins/auth/keycloak/__init__.py | 5 + .../server/plugins/auth/keycloak/plugin.py | 45 + .../server/plugins/auth/keycloak/provider.py | 74 ++ .../server/plugins/auth/oci/__init__.py | 5 + src/fastmcp/server/plugins/auth/oci/plugin.py | 61 ++ .../server/plugins/auth/oci/provider.py | 180 ++++ .../plugins/auth/propelauth/__init__.py | 5 + .../server/plugins/auth/propelauth/plugin.py | 77 ++ .../plugins/auth/propelauth/provider.py | 234 ++++++ src/fastmcp/server/plugins/auth/providers.py | 792 ------------------ .../server/plugins/auth/scalekit/__init__.py | 5 + .../server/plugins/auth/scalekit/plugin.py | 56 ++ .../server/plugins/auth/scalekit/provider.py | 212 +++++ src/fastmcp/server/plugins/auth/supabase.py | 5 - .../server/plugins/auth/supabase/__init__.py | 5 + .../server/plugins/auth/supabase/plugin.py | 53 ++ .../server/plugins/auth/supabase/provider.py | 181 ++++ .../server/plugins/auth/workos/__init__.py | 5 + .../server/plugins/auth/workos/plugin.py | 62 ++ .../server/plugins/auth/workos/provider.py | 245 ++++++ src/fastmcp/server/plugins/base.py | 15 + .../server/plugins/code_mode/plugin.py | 7 +- .../server/plugins/openapi/__init__.py | 4 +- src/fastmcp/server/plugins/openapi/plugin.py | 12 +- .../server/plugins/prompts_as_tools/plugin.py | 4 + .../plugins/resources_as_tools/plugin.py | 4 + src/fastmcp/server/plugins/skills/__init__.py | 4 +- src/fastmcp/server/plugins/skills/plugin.py | 14 +- .../server/plugins/tool_search/plugin.py | 8 +- .../deprecated/test_auth_provider_imports.py | 137 +++ .../auth/test_github_provider_integration.py | 2 +- .../test_keycloak_provider_integration.py | 2 +- tests/server/auth/providers/test_auth0.py | 2 +- tests/server/auth/providers/test_aws.py | 2 +- tests/server/auth/providers/test_azure.py | 2 +- .../auth/providers/test_azure_scopes.py | 13 +- tests/server/auth/providers/test_clerk.py | 2 +- tests/server/auth/providers/test_descope.py | 2 +- tests/server/auth/providers/test_discord.py | 7 +- tests/server/auth/providers/test_github.py | 14 +- tests/server/auth/providers/test_google.py | 2 +- .../server/auth/providers/test_http_client.py | 20 +- tests/server/auth/providers/test_keycloak.py | 2 +- .../server/auth/providers/test_propelauth.py | 4 +- tests/server/auth/providers/test_scalekit.py | 4 +- tests/server/auth/providers/test_supabase.py | 2 +- tests/server/auth/providers/test_workos.py | 4 +- tests/server/plugins/test_auth_plugins.py | 109 ++- tests/server/plugins/test_code_mode_plugin.py | 5 +- tests/server/plugins/test_openapi_plugin.py | 27 +- tests/server/plugins/test_skills_plugin.py | 17 +- tests/server/plugins/test_tool_search.py | 13 +- tests/server/test_plugins.py | 1 + 137 files changed, 6028 insertions(+), 5335 deletions(-) create mode 100644 src/fastmcp/server/plugins/auth/_base.py create mode 100644 src/fastmcp/server/plugins/auth/auth0/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/auth0/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/auth0/provider.py create mode 100644 src/fastmcp/server/plugins/auth/authkit/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/authkit/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/authkit/provider.py create mode 100644 src/fastmcp/server/plugins/auth/aws/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/aws/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/aws/provider.py create mode 100644 src/fastmcp/server/plugins/auth/azure/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/azure/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/azure/provider.py create mode 100644 src/fastmcp/server/plugins/auth/clerk/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/clerk/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/clerk/provider.py create mode 100644 src/fastmcp/server/plugins/auth/descope/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/descope/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/descope/provider.py create mode 100644 src/fastmcp/server/plugins/auth/discord/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/discord/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/discord/provider.py create mode 100644 src/fastmcp/server/plugins/auth/github/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/github/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/github/provider.py create mode 100644 src/fastmcp/server/plugins/auth/google/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/google/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/google/provider.py create mode 100644 src/fastmcp/server/plugins/auth/keycloak/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/keycloak/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/keycloak/provider.py create mode 100644 src/fastmcp/server/plugins/auth/oci/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/oci/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/oci/provider.py create mode 100644 src/fastmcp/server/plugins/auth/propelauth/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/propelauth/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/propelauth/provider.py delete mode 100644 src/fastmcp/server/plugins/auth/providers.py create mode 100644 src/fastmcp/server/plugins/auth/scalekit/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/scalekit/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/scalekit/provider.py delete mode 100644 src/fastmcp/server/plugins/auth/supabase.py create mode 100644 src/fastmcp/server/plugins/auth/supabase/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/supabase/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/supabase/provider.py create mode 100644 src/fastmcp/server/plugins/auth/workos/__init__.py create mode 100644 src/fastmcp/server/plugins/auth/workos/plugin.py create mode 100644 src/fastmcp/server/plugins/auth/workos/provider.py create mode 100644 tests/deprecated/test_auth_provider_imports.py diff --git a/docs/deployment/http.mdx b/docs/deployment/http.mdx index 54132eb0e..8400b39c2 100644 --- a/docs/deployment/http.mdx +++ b/docs/deployment/http.mdx @@ -478,7 +478,7 @@ When mounting an OAuth-protected server under a path prefix, declare your URLs u ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from starlette.applications import Starlette from starlette.routing import Mount @@ -540,7 +540,7 @@ Here's a complete working example showing all the pieces together: ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from starlette.applications import Starlette from starlette.routing import Mount import uvicorn diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 476349a01..8d54a592f 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -97,7 +97,7 @@ async def my_tool( For Azure/Entra, the new `fastmcp[azure]` extra adds `EntraOBOToken`, which handles the On-Behalf-Of token exchange declaratively: ```python -from fastmcp.server.auth.providers.azure import EntraOBOToken +from fastmcp.server.plugins.auth.azure.provider import EntraOBOToken @mcp.tool() async def get_emails( diff --git a/docs/getting-started/upgrading/from-fastmcp-2.mdx b/docs/getting-started/upgrading/from-fastmcp-2.mdx index 1e659e76a..4b461aa5d 100644 --- a/docs/getting-started/upgrading/from-fastmcp-2.mdx +++ b/docs/getting-started/upgrading/from-fastmcp-2.mdx @@ -252,7 +252,7 @@ auth = GitHubProvider() # After (v3) — pass values explicitly import os -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider auth = GitHubProvider( client_id=os.environ["GITHUB_CLIENT_ID"], diff --git a/docs/integrations/auth0.mdx b/docs/integrations/auth0.mdx index 65f9d3873..bf66feee5 100644 --- a/docs/integrations/auth0.mdx +++ b/docs/integrations/auth0.mdx @@ -47,7 +47,7 @@ Create an Application in your Auth0 settings to get the credentials needed for a - If you want to use a custom callback path (e.g., `/auth/auth0/callback`), make sure to set the same path in both your Auth0 Application settings and the `redirect_path` parameter when configuring the Auth0Provider. + If you want to use a custom callback path (e.g., `/auth/auth0/callback`), make sure to set the same path in both your Auth0 Application settings and the `redirect_path` parameter when configuring the Auth0 plugin. @@ -76,23 +76,25 @@ Create an Application in your Auth0 settings to get the credentials needed for a ### Step 2: FastMCP Configuration -Create your FastMCP server using the `Auth0Provider`. +Create your FastMCP server using the `Auth0Auth` plugin. ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.plugins.auth.auth0 import Auth0Auth -# The Auth0Provider utilizes Auth0 OIDC configuration -auth_provider = Auth0Provider( - config_url="https://.../.well-known/openid-configuration", # Your Auth0 configuration URL - client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", # Your Auth0 application Client ID - client_secret="vPYqbjemq...", # Your Auth0 application Client Secret - audience="https://...", # Your Auth0 API audience - base_url="http://localhost:8000", # Must match your application configuration - # redirect_path="/auth/callback" # Default value, customize if needed +# The Auth0 plugin utilizes Auth0 OIDC configuration +auth_plugin = Auth0Auth( + Auth0Auth.Config( + config_url="https://.../.well-known/openid-configuration", # Your Auth0 configuration URL + client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", # Your Auth0 application Client ID + client_secret="vPYqbjemq...", # Your Auth0 application Client Secret + audience="https://...", # Your Auth0 API audience + base_url="http://localhost:8000", # Must match your application configuration + # redirect_path="/auth/callback" # Default value, customize if needed + ) ) -mcp = FastMCP(name="Auth0 Secured App", auth=auth_provider) +mcp = FastMCP(name="Auth0 Secured App", plugins=[auth_plugin]) # Add a protected tool to test authentication @mcp.tool @@ -157,21 +159,21 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.plugins.auth.auth0 import Auth0Auth from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage -auth_provider = Auth0Provider( - config_url="https://.../.well-known/openid-configuration", - client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", - client_secret="vPYqbjemq...", - audience="https://...", - base_url="https://your-production-domain.com", - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], +auth_plugin = Auth0Auth( + Auth0Auth.Config( + config_url="https://.../.well-known/openid-configuration", + client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", + client_secret="vPYqbjemq...", + audience="https://...", + base_url="https://your-production-domain.com", + jwt_signing_key=os.environ["JWT_SIGNING_KEY"], + ), client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], @@ -181,7 +183,7 @@ auth_provider = Auth0Provider( ) ) -mcp = FastMCP(name="Production Auth0 App", auth=auth_provider) +mcp = FastMCP(name="Production Auth0 App", plugins=[auth_plugin]) ``` diff --git a/docs/integrations/authkit.mdx b/docs/integrations/authkit.mdx index c77175201..c530779ff 100644 --- a/docs/integrations/authkit.mdx +++ b/docs/integrations/authkit.mdx @@ -44,20 +44,22 @@ In the WorkOS Dashboard, go to **Connect → Configuration** and configure: ### Step 2: FastMCP Configuration -Create your FastMCP server file and use the `AuthKitProvider` to handle all the OAuth integration automatically: +Create your FastMCP server file and use the `AuthKitAuth` plugin to handle the OAuth integration automatically: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider +from fastmcp.server.plugins.auth.authkit import AuthKitAuth -# AuthKitProvider automatically discovers WorkOS endpoints, configures JWT +# AuthKitAuth automatically discovers WorkOS endpoints, configures JWT # validation, and binds the token audience to this server's resource URL. -auth_provider = AuthKitProvider( - authkit_domain="https://your-project-12345.authkit.app", - base_url="http://127.0.0.1:8000", # Use your actual server URL +auth_plugin = AuthKitAuth( + AuthKitAuth.Config( + authkit_domain="https://your-project-12345.authkit.app", + base_url="http://127.0.0.1:8000", # Use your actual server URL + ) ) -mcp = FastMCP(name="AuthKit Secured App", auth=auth_provider) +mcp = FastMCP(name="AuthKit Secured App", plugins=[auth_plugin]) ``` When the server starts, it logs the resource URL it is validating against. Paste that URL into your Dashboard's **MCP resource indicators** list. @@ -94,13 +96,15 @@ For production deployments, load sensitive configuration from environment variab ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider +from fastmcp.server.plugins.auth.authkit import AuthKitAuth # Load configuration from environment variables -auth = AuthKitProvider( - authkit_domain=os.environ.get("AUTHKIT_DOMAIN"), - base_url=os.environ.get("BASE_URL", "https://your-server.com"), +auth_plugin = AuthKitAuth( + AuthKitAuth.Config( + authkit_domain=os.environ.get("AUTHKIT_DOMAIN"), + base_url=os.environ.get("BASE_URL", "https://your-server.com"), + ) ) -mcp = FastMCP(name="AuthKit Secured App", auth=auth) +mcp = FastMCP(name="AuthKit Secured App", plugins=[auth_plugin]) ``` diff --git a/docs/integrations/aws-cognito.mdx b/docs/integrations/aws-cognito.mdx index b7df29222..a7be4e16e 100644 --- a/docs/integrations/aws-cognito.mdx +++ b/docs/integrations/aws-cognito.mdx @@ -116,24 +116,26 @@ Set up AWS Cognito user pool with an app client to get the credentials needed fo ### Step 2: FastMCP Configuration -Create your FastMCP server using the `AWSCognitoProvider`, which handles AWS Cognito's JWT tokens and user claims automatically: +Create your FastMCP server using the `AWSCognitoAuth` plugin, which handles AWS Cognito's JWT tokens and user claims automatically: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.aws import AWSCognitoProvider +from fastmcp.server.plugins.auth.aws import AWSCognitoAuth from fastmcp.server.dependencies import get_access_token -# The AWSCognitoProvider handles JWT validation and user claims -auth_provider = AWSCognitoProvider( - user_pool_id="eu-central-1_XXXXXXXXX", # Your AWS Cognito user pool ID - aws_region="eu-central-1", # AWS region (defaults to eu-central-1) - client_id="your-app-client-id", # Your app client ID - client_secret="your-app-client-secret", # Your app client Secret - base_url="http://localhost:8000", # Must match your callback URL - # redirect_path="/auth/callback" # Default value, customize if needed +# The AWSCognitoAuth plugin handles JWT validation and user claims +auth_plugin = AWSCognitoAuth( + AWSCognitoAuth.Config( + user_pool_id="eu-central-1_XXXXXXXXX", # Your AWS Cognito user pool ID + aws_region="eu-central-1", # AWS region (defaults to eu-central-1) + client_id="your-app-client-id", # Your app client ID + client_secret="your-app-client-secret", # Your app client Secret + base_url="http://localhost:8000", # Must match your callback URL + # redirect_path="/auth/callback" # Default value, customize if needed + ) ) -mcp = FastMCP(name="AWS Cognito Secured App", auth=auth_provider) +mcp = FastMCP(name="AWS Cognito Secured App", plugins=[auth_plugin]) # Add a protected tool to test authentication @mcp.tool @@ -204,21 +206,21 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.aws import AWSCognitoProvider +from fastmcp.server.plugins.auth.aws import AWSCognitoAuth from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage -auth_provider = AWSCognitoProvider( - user_pool_id="eu-central-1_XXXXXXXXX", - aws_region="eu-central-1", - client_id="your-app-client-id", - client_secret="your-app-client-secret", - base_url="https://your-production-domain.com", - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], +auth_plugin = AWSCognitoAuth( + AWSCognitoAuth.Config( + user_pool_id="eu-central-1_XXXXXXXXX", + aws_region="eu-central-1", + client_id="your-app-client-id", + client_secret="your-app-client-secret", + base_url="https://your-production-domain.com", + jwt_signing_key=os.environ["JWT_SIGNING_KEY"], + ), client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], @@ -228,7 +230,7 @@ auth_provider = AWSCognitoProvider( ) ) -mcp = FastMCP(name="Production AWS Cognito App", auth=auth_provider) +mcp = FastMCP(name="Production AWS Cognito App", plugins=[auth_plugin]) ``` @@ -275,4 +277,4 @@ Perfect for enterprise environments with: - **Multi-Factor Authentication (MFA)**: Leverage AWS Cognito's built-in MFA - **User Groups**: Role-based access control through AWS Cognito groups - **Custom Attributes**: Access custom user attributes defined in your AWS Cognito user pool -- **Compliance**: Meet enterprise security and compliance requirements \ No newline at end of file +- **Compliance**: Meet enterprise security and compliance requirements diff --git a/docs/integrations/azure.mdx b/docs/integrations/azure.mdx index 4376a38ce..95bd159a4 100644 --- a/docs/integrations/azure.mdx +++ b/docs/integrations/azure.mdx @@ -46,7 +46,7 @@ Create an App registration in Azure Portal to get the credentials needed for aut - If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the AzureProvider. + If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the Azure plugin. - **Expose an API**: Configure your Application ID URI and define scopes @@ -74,7 +74,7 @@ Create an App registration in Azure Portal to get the credentials needed for aut - In FastMCP's `AzureProvider`, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`. + In FastMCP's Azure plugin, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`. @@ -109,28 +109,30 @@ Create an App registration in Azure Portal to get the credentials needed for aut ### Step 2: FastMCP Configuration -Create your FastMCP server using the `AzureProvider`, which handles Azure's OAuth flow automatically: +Create your FastMCP server using the `AzureAuth` plugin, which handles Azure's OAuth flow automatically: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.plugins.auth.azure import AzureAuth -# The AzureProvider handles Azure's token format and validation -auth_provider = AzureProvider( - client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", # Your Azure App Client ID - client_secret="your-client-secret", # Your Azure App Client Secret - tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED) - base_url="http://localhost:8000", # Must match your App registration - required_scopes=["your-scope"], # At least one scope REQUIRED - name of scope from your App - # identifier_uri defaults to api://{client_id} - # identifier_uri="api://your-api-id", - # Optional: request additional upstream scopes in the authorize request - # additional_authorize_scopes=["User.Read", "openid", "email"], - # redirect_path="/auth/callback" # Default value, customize if needed - # base_authority="login.microsoftonline.us" # For Azure Government (default: login.microsoftonline.com) +# The AzureAuth plugin handles Azure's token format and validation +auth_plugin = AzureAuth( + AzureAuth.Config( + client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", # Your Azure App Client ID + client_secret="your-client-secret", # Your Azure App Client Secret + tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED) + base_url="http://localhost:8000", # Must match your App registration + required_scopes=["your-scope"], # At least one scope REQUIRED - name of scope from your App + # identifier_uri defaults to api://{client_id} + # identifier_uri="api://your-api-id", + # Optional: request additional upstream scopes in the authorize request + # additional_authorize_scopes=["User.Read", "openid", "email"], + # redirect_path="/auth/callback" # Default value, customize if needed + # base_authority="login.microsoftonline.us" # For Azure Government (default: login.microsoftonline.com) + ) ) -mcp = FastMCP(name="Azure Secured App", auth=auth_provider) +mcp = FastMCP(name="Azure Secured App", plugins=[auth_plugin]) # Add a protected tool to test authentication @mcp.tool @@ -139,7 +141,7 @@ async def get_user_info() -> dict: from fastmcp.server.dependencies import get_access_token token = get_access_token() - # The AzureProvider stores user data in token claims + # The Azure plugin stores user data in token claims return { "azure_id": token.claims.get("sub"), "email": token.claims.get("email"), @@ -250,21 +252,21 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.plugins.auth.azure import AzureAuth from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage -auth_provider = AzureProvider( - client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", - client_secret="your-client-secret", - tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", - base_url="https://your-production-domain.com", - required_scopes=["your-scope"], - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], +auth_plugin = AzureAuth( + AzureAuth.Config( + client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", + client_secret="your-client-secret", + tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", + base_url="https://your-production-domain.com", + required_scopes=["your-scope"], + jwt_signing_key=os.environ["JWT_SIGNING_KEY"], + ), client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], @@ -274,7 +276,7 @@ auth_provider = AzureProvider( ) ) -mcp = FastMCP(name="Production Azure App", auth=auth_provider) +mcp = FastMCP(name="Production Azure App", plugins=[auth_plugin]) ``` @@ -287,7 +289,7 @@ For complete details on these parameters, see the [OAuth Proxy documentation](/s -For deployments where your server only needs to **validate incoming tokens** — such as Azure Container Apps with Managed Identity — use `AzureJWTVerifier` with `RemoteAuthProvider` instead of the full `AzureProvider`. +For deployments where your server only needs to **validate incoming tokens** — such as Azure Container Apps with Managed Identity — use `AzureJWTVerifier` with `RemoteAuthProvider` instead of the full Azure auth plugin. This pattern is ideal when: - Your infrastructure handles authentication (e.g., Managed Identity) @@ -297,7 +299,7 @@ This pattern is ideal when: ```python server.py from fastmcp import FastMCP from fastmcp.server.auth import RemoteAuthProvider -from fastmcp.server.auth.providers.azure import AzureJWTVerifier +from fastmcp.server.plugins.auth.azure.provider import AzureJWTVerifier from pydantic import AnyHttpUrl tenant_id = "your-tenant-id" @@ -371,29 +373,31 @@ OBO requires additional configuration in your Azure App registration beyond basi -### Configure AzureProvider for OBO +### Configure AzureAuth for OBO The `additional_authorize_scopes` parameter tells Azure which downstream API permissions to include during the initial authorization. These scopes establish what your server can request through OBO later. ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.plugins.auth.azure import AzureAuth -auth_provider = AzureProvider( - client_id="your-client-id", - client_secret="your-client-secret", - tenant_id="your-tenant-id", - base_url="http://localhost:8000", - required_scopes=["mcp-access"], # Your API scope - # Include Graph scopes for OBO - additional_authorize_scopes=[ - "https://graph.microsoft.com/Mail.Read", - "https://graph.microsoft.com/User.Read", - "offline_access", # Enables refresh tokens - ], +auth_plugin = AzureAuth( + AzureAuth.Config( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + base_url="http://localhost:8000", + required_scopes=["mcp-access"], # Your API scope + # Include Graph scopes for OBO + additional_authorize_scopes=[ + "https://graph.microsoft.com/Mail.Read", + "https://graph.microsoft.com/User.Read", + "offline_access", # Enables refresh tokens + ], + ) ) -mcp = FastMCP(name="Graph-Enabled Server", auth=auth_provider) +mcp = FastMCP(name="Graph-Enabled Server", plugins=[auth_plugin]) ``` Scopes listed in `additional_authorize_scopes` are requested during the initial OAuth flow but aren't validated on incoming tokens. They establish permission for your server to later exchange the user's token for downstream API access. @@ -408,22 +412,25 @@ The `EntraOBOToken` dependency handles the complete OBO flow automatically. Decl ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider, EntraOBOToken +from fastmcp.server.plugins.auth.azure import AzureAuth +from fastmcp.server.plugins.auth.azure.provider import EntraOBOToken import httpx -auth_provider = AzureProvider( - client_id="your-client-id", - client_secret="your-client-secret", - tenant_id="your-tenant-id", - base_url="http://localhost:8000", - required_scopes=["mcp-access"], - additional_authorize_scopes=[ - "https://graph.microsoft.com/Mail.Read", - "https://graph.microsoft.com/User.Read", - ], +auth_plugin = AzureAuth( + AzureAuth.Config( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + base_url="http://localhost:8000", + required_scopes=["mcp-access"], + additional_authorize_scopes=[ + "https://graph.microsoft.com/Mail.Read", + "https://graph.microsoft.com/User.Read", + ], + ) ) -mcp = FastMCP(name="Email Reader", auth=auth_provider) +mcp = FastMCP(name="Email Reader", plugins=[auth_plugin]) @mcp.tool async def get_recent_emails( diff --git a/docs/integrations/descope.mdx b/docs/integrations/descope.mdx index bfb6cd9c8..304784e1e 100644 --- a/docs/integrations/descope.mdx +++ b/docs/integrations/descope.mdx @@ -54,21 +54,23 @@ SERVER_URL=http://localhost:3000 # Your server's base URL ### Step 3: FastMCP Configuration -Create your FastMCP server file and use the DescopeProvider to handle all the OAuth integration automatically: +Create your FastMCP server file and use the `DescopeAuth` plugin to handle the OAuth integration automatically: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.descope import DescopeProvider +from fastmcp.server.plugins.auth.descope import DescopeAuth -# The DescopeProvider automatically discovers Descope endpoints +# The DescopeAuth plugin automatically discovers Descope endpoints # and configures JWT token validation -auth_provider = DescopeProvider( - config_url="https://.../.well-known/openid-configuration", # Your MCP Server .well-known URL - base_url=SERVER_URL, # Your server's public URL +auth_plugin = DescopeAuth( + DescopeAuth.Config( + config_url="https://.../.well-known/openid-configuration", # Your MCP Server .well-known URL + base_url=SERVER_URL, # Your server's public URL + ) ) -# Create FastMCP server with auth -mcp = FastMCP(name="My Descope Protected Server", auth=auth_provider) +# Create FastMCP server with auth plugin +mcp = FastMCP(name="My Descope Protected Server", plugins=[auth_plugin]) ``` @@ -101,13 +103,15 @@ For production deployments, load configuration from environment variables: ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.descope import DescopeProvider +from fastmcp.server.plugins.auth.descope import DescopeAuth # Load configuration from environment variables -auth = DescopeProvider( - config_url=os.environ.get("DESCOPE_CONFIG_URL"), - base_url=os.environ.get("BASE_URL", "https://your-server.com") +auth_plugin = DescopeAuth( + DescopeAuth.Config( + config_url=os.environ.get("DESCOPE_CONFIG_URL"), + base_url=os.environ.get("BASE_URL", "https://your-server.com"), + ) ) -mcp = FastMCP(name="My Descope Protected Server", auth=auth) +mcp = FastMCP(name="My Descope Protected Server", plugins=[auth_plugin]) ``` diff --git a/docs/integrations/discord.mdx b/docs/integrations/discord.mdx index 5d6c643b7..19d943e11 100644 --- a/docs/integrations/discord.mdx +++ b/docs/integrations/discord.mdx @@ -56,19 +56,21 @@ Create an application in the Discord Developer Portal to get the credentials nee ### Step 2: FastMCP Configuration -Create your FastMCP server using the `DiscordProvider`, which handles Discord's OAuth flow automatically: +Create your FastMCP server using the `DiscordAuth` plugin, which handles Discord's OAuth flow automatically: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.discord import DiscordProvider +from fastmcp.server.plugins.auth.discord import DiscordAuth -auth_provider = DiscordProvider( - client_id="12345", # Your Discord Application Client ID - client_secret="your-client-secret", # Your Discord OAuth Client Secret - base_url="http://localhost:8000", # Must match your OAuth configuration +auth_plugin = DiscordAuth( + DiscordAuth.Config( + client_id="12345", # Your Discord Application Client ID + client_secret="your-client-secret", # Your Discord OAuth Client Secret + base_url="http://localhost:8000", # Must match your OAuth configuration + ) ) -mcp = FastMCP(name="Discord Secured App", auth=auth_provider) +mcp = FastMCP(name="Discord Secured App", plugins=[auth_plugin]) @mcp.tool async def get_user_info() -> dict: @@ -138,11 +140,13 @@ Discord OAuth supports several scopes for accessing different types of user data To request additional scopes: ```python -auth_provider = DiscordProvider( - client_id="...", - client_secret="...", - base_url="http://localhost:8000", - required_scopes=["identify", "email"], +auth_plugin = DiscordAuth( + DiscordAuth.Config( + client_id="...", + client_secret="...", + base_url="http://localhost:8000", + required_scopes=["identify", "email"], + ) ) ``` @@ -153,17 +157,18 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.discord import DiscordProvider +from fastmcp.server.plugins.auth.discord import DiscordAuth from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet -auth_provider = DiscordProvider( - client_id="12345", - client_secret=os.environ["DISCORD_CLIENT_SECRET"], - base_url="https://your-production-domain.com", - - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], +auth_plugin = DiscordAuth( + DiscordAuth.Config( + client_id="12345", + client_secret=os.environ["DISCORD_CLIENT_SECRET"], + base_url="https://your-production-domain.com", + jwt_signing_key=os.environ["JWT_SIGNING_KEY"], + ), client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], @@ -173,7 +178,7 @@ auth_provider = DiscordProvider( ) ) -mcp = FastMCP(name="Production Discord App", auth=auth_provider) +mcp = FastMCP(name="Production Discord App", plugins=[auth_plugin]) ``` diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index d493eb1ef..530788b6b 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -42,7 +42,7 @@ Create an OAuth App in your GitHub settings to get the credentials needed for au - If you want to use a custom callback path (e.g., `/auth/github/callback`), make sure to set the same path in both your GitHub OAuth App settings and the `redirect_path` parameter when configuring the GitHubProvider. + If you want to use a custom callback path (e.g., `/auth/github/callback`), make sure to set the same path in both your GitHub OAuth App settings and the `redirect_path` parameter when configuring the GitHub plugin. @@ -60,21 +60,23 @@ Create an OAuth App in your GitHub settings to get the credentials needed for au ### Step 2: FastMCP Configuration -Create your FastMCP server using the `GitHubProvider`, which handles GitHub's OAuth quirks automatically: +Create your FastMCP server using the `GitHubAuth` plugin, which handles GitHub's OAuth quirks automatically: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github import GitHubAuth -# The GitHubProvider handles GitHub's token format and validation -auth_provider = GitHubProvider( - client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID - client_secret="github_pat_...", # Your GitHub OAuth App Client Secret - base_url="http://localhost:8000", # Must match your OAuth App configuration - # redirect_path="/auth/callback" # Default value, customize if needed +# The GitHubAuth plugin handles GitHub's token format and validation +auth_plugin = GitHubAuth( + GitHubAuth.Config( + client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID + client_secret="github_pat_...", # Your GitHub OAuth App Client Secret + base_url="http://localhost:8000", # Must match your OAuth App configuration + # redirect_path="/auth/callback" # Default value, customize if needed + ) ) -mcp = FastMCP(name="GitHub Secured App", auth=auth_provider) +mcp = FastMCP(name="GitHub Secured App", plugins=[auth_plugin]) # Add a protected tool to test authentication @mcp.tool @@ -83,7 +85,7 @@ async def get_user_info() -> dict: from fastmcp.server.dependencies import get_access_token token = get_access_token() - # The GitHubProvider stores user data in token claims + # The GitHub auth plugin stores user data in token claims return { "github_user": token.claims.get("login"), "name": token.claims.get("name"), @@ -143,19 +145,19 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github import GitHubAuth from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage -auth_provider = GitHubProvider( - client_id="Ov23liAbcDefGhiJkLmN", - client_secret="github_pat_...", - base_url="https://your-production-domain.com", - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], +auth_plugin = GitHubAuth( + GitHubAuth.Config( + client_id="Ov23liAbcDefGhiJkLmN", + client_secret="github_pat_...", + base_url="https://your-production-domain.com", + jwt_signing_key=os.environ["JWT_SIGNING_KEY"], + ), client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], @@ -165,7 +167,7 @@ auth_provider = GitHubProvider( ) ) -mcp = FastMCP(name="Production GitHub App", auth=auth_provider) +mcp = FastMCP(name="Production GitHub App", plugins=[auth_plugin]) ``` diff --git a/docs/integrations/google.mdx b/docs/integrations/google.mdx index 17d49d12f..df0d8152f 100644 --- a/docs/integrations/google.mdx +++ b/docs/integrations/google.mdx @@ -45,7 +45,7 @@ Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credential - If you want to use a custom callback path (e.g., `/auth/google/callback`), make sure to set the same path in both your Google OAuth Client settings and the `redirect_path` parameter when configuring the GoogleProvider. + If you want to use a custom callback path (e.g., `/auth/google/callback`), make sure to set the same path in both your Google OAuth Client settings and the `redirect_path` parameter when configuring the Google plugin. @@ -65,25 +65,27 @@ Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credential ### Step 2: FastMCP Configuration -Create your FastMCP server using the `GoogleProvider`, which handles Google's OAuth flow automatically: +Create your FastMCP server using the `GoogleAuth` plugin, which handles Google's OAuth flow automatically: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.plugins.auth.google import GoogleAuth -# The GoogleProvider handles Google's token format and validation -auth_provider = GoogleProvider( - client_id="123456789.apps.googleusercontent.com", # Your Google OAuth Client ID - client_secret="GOCSPX-abc123...", # Your Google OAuth Client Secret - base_url="http://localhost:8000", # Must match your OAuth configuration - required_scopes=[ # Request user information - "openid", - "https://www.googleapis.com/auth/userinfo.email", - ], - # redirect_path="/auth/callback" # Default value, customize if needed +# The GoogleAuth plugin handles Google's token format and validation +auth_plugin = GoogleAuth( + GoogleAuth.Config( + client_id="123456789.apps.googleusercontent.com", # Your Google OAuth Client ID + client_secret="GOCSPX-abc123...", # Your Google OAuth Client Secret + base_url="http://localhost:8000", # Must match your OAuth configuration + required_scopes=[ # Request user information + "openid", + "https://www.googleapis.com/auth/userinfo.email", + ], + # redirect_path="/auth/callback" # Default value, customize if needed + ) ) -mcp = FastMCP(name="Google Secured App", auth=auth_provider) +mcp = FastMCP(name="Google Secured App", plugins=[auth_plugin]) # Add a protected tool to test authentication @mcp.tool @@ -92,7 +94,7 @@ async def get_user_info() -> dict: from fastmcp.server.dependencies import get_access_token token = get_access_token() - # The GoogleProvider stores user data in token claims + # The Google auth plugin stores user data in token claims return { "google_id": token.claims.get("sub"), "email": token.claims.get("email"), @@ -156,20 +158,20 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.plugins.auth.google import GoogleAuth from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage -auth_provider = GoogleProvider( - client_id="123456789.apps.googleusercontent.com", - client_secret="GOCSPX-abc123...", - base_url="https://your-production-domain.com", - required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"], - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], +auth_plugin = GoogleAuth( + GoogleAuth.Config( + client_id="123456789.apps.googleusercontent.com", + client_secret="GOCSPX-abc123...", + base_url="https://your-production-domain.com", + required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"], + jwt_signing_key=os.environ["JWT_SIGNING_KEY"], + ), client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], @@ -179,11 +181,11 @@ auth_provider = GoogleProvider( ) ) -mcp = FastMCP(name="Production Google App", auth=auth_provider) +mcp = FastMCP(name="Production Google App", plugins=[auth_plugin]) ``` Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). - \ No newline at end of file + diff --git a/docs/integrations/keycloak.mdx b/docs/integrations/keycloak.mdx index 22d61f132..6ec42d3e2 100644 --- a/docs/integrations/keycloak.mdx +++ b/docs/integrations/keycloak.mdx @@ -27,22 +27,24 @@ Before you begin, you will need: ### FastMCP Configuration -Create your FastMCP server and use `KeycloakAuthProvider` to handle OAuth: +Create your FastMCP server and use the `KeycloakAuth` plugin to handle OAuth: ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider +from fastmcp.server.plugins.auth.keycloak import KeycloakAuth from fastmcp.server.dependencies import get_access_token -auth = KeycloakAuthProvider( - realm_url=os.getenv("KEYCLOAK_REALM_URL") or "http://localhost:8080/realms/myrealm", - base_url="http://localhost:8000", - # audience="http://localhost:8000", # Recommended for production +auth_plugin = KeycloakAuth( + KeycloakAuth.Config( + realm_url=os.getenv("KEYCLOAK_REALM_URL") or "http://localhost:8080/realms/myrealm", + base_url="http://localhost:8000", + # audience="http://localhost:8000", # Recommended for production + ) ) -mcp = FastMCP("Keycloak Example Server", auth=auth) +mcp = FastMCP("Keycloak Example Server", plugins=[auth_plugin]) @mcp.tool @@ -124,7 +126,7 @@ async def admin_only_tool() -> str: ```python from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider +from fastmcp.server.plugins.auth.keycloak import KeycloakAuth custom_verifier = JWTVerifier( jwks_uri="http://localhost:8080/realms/myrealm/protocol/openid-connect/certs", @@ -133,9 +135,11 @@ custom_verifier = JWTVerifier( required_scopes=["api:read", "api:write"], ) -auth = KeycloakAuthProvider( - realm_url="http://localhost:8080/realms/myrealm", - base_url="http://localhost:8000", +auth_plugin = KeycloakAuth( + KeycloakAuth.Config( + realm_url="http://localhost:8080/realms/myrealm", + base_url="http://localhost:8000", + ), token_verifier=custom_verifier, ) ``` diff --git a/docs/integrations/oci.mdx b/docs/integrations/oci.mdx index 02fa36dae..baff973fb 100644 --- a/docs/integrations/oci.mdx +++ b/docs/integrations/oci.mdx @@ -87,7 +87,7 @@ Follow the Steps as mentioned below to create an OAuth client. Click on "Submit" button to update OAuth configuration for the client application. **Note: You don't need to do any special configuration to support PKCE for the OAuth client.** Make sure to Activate the client application. - Note down client ID and client secret for the application. You'll use these values when configuring the OCIProvider in your code. + Note down client ID and client secret for the application. You'll use these values when configuring the OCI plugin in your code. @@ -209,7 +209,7 @@ For production deployments with persistent token management across server restar import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.oci import OCIProvider +from fastmcp.server.plugins.auth.oci import OCIAuth from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper @@ -217,14 +217,14 @@ from cryptography.fernet import Fernet # Load configuration from environment # Production setup with encrypted persistent token storage -auth_provider = OCIProvider( - config_url=os.environ.get("OCI_CONFIG_URL"), - client_id=os.environ.get("OCI_CLIENT_ID"), - client_secret=os.environ.get("OCI_CLIENT_SECRET"), - base_url=os.environ.get("BASE_URL", "https://your-production-domain.com"), - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], +auth_plugin = OCIAuth( + OCIAuth.Config( + config_url=os.environ.get("OCI_CONFIG_URL"), + client_id=os.environ.get("OCI_CLIENT_ID"), + client_secret=os.environ.get("OCI_CLIENT_SECRET"), + base_url=os.environ.get("BASE_URL", "https://your-production-domain.com"), + jwt_signing_key=os.environ["JWT_SIGNING_KEY"], + ), client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], @@ -234,7 +234,7 @@ auth_provider = OCIProvider( ) ) -mcp = FastMCP(name="Production OCI App", auth=auth_provider) +mcp = FastMCP(name="Production OCI App", plugins=[auth_plugin]) ``` @@ -245,4 +245,4 @@ For complete details on these parameters, see the [OAuth Proxy documentation](/s The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. - \ No newline at end of file + diff --git a/docs/integrations/propelauth.mdx b/docs/integrations/propelauth.mdx index 7f21d2010..8fbafe3d7 100644 --- a/docs/integrations/propelauth.mdx +++ b/docs/integrations/propelauth.mdx @@ -67,22 +67,24 @@ SERVER_URL=http://localhost:8000 # Your server's base U ### Step 3: FastMCP Configuration -Create your FastMCP server file and use the PropelAuthProvider to handle all the OAuth integration automatically: +Create your FastMCP server file and use the `PropelAuth` plugin to handle the OAuth integration automatically: ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.propelauth import PropelAuthProvider +from fastmcp.server.plugins.auth.propelauth import PropelAuth -auth_provider = PropelAuthProvider( - auth_url=os.environ["PROPELAUTH_AUTH_URL"], - introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], - introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], - base_url=os.environ["SERVER_URL"], - required_scopes=["read:user_data"], # Optional scope enforcement +auth_plugin = PropelAuth( + PropelAuth.Config( + auth_url=os.environ["PROPELAUTH_AUTH_URL"], + introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], + introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], + base_url=os.environ["SERVER_URL"], + required_scopes=["read:user_data"], # Optional scope enforcement + ) ) -mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth_provider) +mcp = FastMCP(name="My PropelAuth Protected Server", plugins=[auth_plugin]) ``` ## Testing @@ -114,18 +116,20 @@ You can use `get_access_token()` inside your tools to identify the authenticated ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.propelauth import PropelAuthProvider +from fastmcp.server.plugins.auth.propelauth import PropelAuth from fastmcp.server.dependencies import get_access_token -auth = PropelAuthProvider( - auth_url=os.environ["PROPELAUTH_AUTH_URL"], - introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], - introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], - base_url=os.environ["SERVER_URL"], - required_scopes=["read:user_data"], +auth_plugin = PropelAuth( + PropelAuth.Config( + auth_url=os.environ["PROPELAUTH_AUTH_URL"], + introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], + introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], + base_url=os.environ["SERVER_URL"], + required_scopes=["read:user_data"], + ) ) -mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth) +mcp = FastMCP(name="My PropelAuth Protected Server", plugins=[auth_plugin]) @mcp.tool def whoami() -> dict: @@ -139,26 +143,26 @@ def whoami() -> dict: ## Advanced Configuration -The `PropelAuthProvider` supports optional overrides for token introspection behavior, including caching and request timeouts: +The `PropelAuth` plugin supports optional overrides for token introspection behavior, including caching and request timeouts: ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.propelauth import PropelAuthProvider +from fastmcp.server.plugins.auth.propelauth import PropelAuth -auth = PropelAuthProvider( - auth_url=os.environ["PROPELAUTH_AUTH_URL"], - introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], - introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], - base_url=os.environ.get("BASE_URL", "https://your-server.com"), - required_scopes=["read:user_data"], - resource="https://your-server.com/mcp", # Restrict to tokens intended for this server (RFC 8707) - token_introspection_overrides={ - "cache_ttl_seconds": 300, # Cache introspection results for 5 minutes - "max_cache_size": 1000, # Maximum cached tokens - "timeout_seconds": 15, # HTTP request timeout - }, +auth_plugin = PropelAuth( + PropelAuth.Config( + auth_url=os.environ["PROPELAUTH_AUTH_URL"], + introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], + introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], + base_url=os.environ.get("BASE_URL", "https://your-server.com"), + required_scopes=["read:user_data"], + resource="https://your-server.com/mcp", # Restrict to tokens intended for this server (RFC 8707) + introspection_cache_ttl_seconds=300, # Cache introspection results for 5 minutes + introspection_max_cache_size=1000, # Maximum cached tokens + introspection_timeout_seconds=15, # HTTP request timeout + ) ) -mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth) +mcp = FastMCP(name="My PropelAuth Protected Server", plugins=[auth_plugin]) ``` diff --git a/docs/integrations/scalekit.mdx b/docs/integrations/scalekit.mdx index 191b81ca2..0812d3ec0 100644 --- a/docs/integrations/scalekit.mdx +++ b/docs/integrations/scalekit.mdx @@ -43,24 +43,26 @@ BASE_URL=http://localhost:8000/ ### Step 2: Add auth to FastMCP server -Create your FastMCP server file and use the ScalekitProvider to handle all the OAuth integration automatically: +Create your FastMCP server file and use the `ScalekitAuth` plugin to handle the OAuth integration automatically: > **Warning:** The legacy `mcp_url` and `client_id` parameters are deprecated and will be removed in a future release. Use `base_url` instead of `mcp_url` and remove `client_id` from your configuration. ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.scalekit import ScalekitProvider +from fastmcp.server.plugins.auth.scalekit import ScalekitAuth # Discovers Scalekit endpoints and set up JWT token validation -auth_provider = ScalekitProvider( - environment_url=SCALEKIT_ENVIRONMENT_URL, # Scalekit environment URL - resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID - base_url=SERVER_URL, # Public MCP endpoint - required_scopes=["read"], # Optional scope enforcement +auth_plugin = ScalekitAuth( + ScalekitAuth.Config( + environment_url=SCALEKIT_ENVIRONMENT_URL, # Scalekit environment URL + resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID + base_url=SERVER_URL, # Public MCP endpoint + required_scopes=["read"], # Optional scope enforcement + ) ) -# Create FastMCP server with auth -mcp = FastMCP(name="My Scalekit Protected Server", auth=auth_provider) +# Create FastMCP server with auth plugin +mcp = FastMCP(name="My Scalekit Protected Server", plugins=[auth_plugin]) @mcp.tool def auth_status() -> dict: @@ -95,16 +97,18 @@ For production deployments, load configuration from environment variables: ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.scalekit import ScalekitProvider +from fastmcp.server.plugins.auth.scalekit import ScalekitAuth # Load configuration from environment variables -auth = ScalekitProvider( - environment_url=os.environ.get("SCALEKIT_ENVIRONMENT_URL"), - resource_id=os.environ.get("SCALEKIT_RESOURCE_ID"), - base_url=os.environ.get("BASE_URL", "https://your-server.com") +auth_plugin = ScalekitAuth( + ScalekitAuth.Config( + environment_url=os.environ.get("SCALEKIT_ENVIRONMENT_URL"), + resource_id=os.environ.get("SCALEKIT_RESOURCE_ID"), + base_url=os.environ.get("BASE_URL", "https://your-server.com"), + ) ) -mcp = FastMCP(name="My Scalekit Protected Server", auth=auth) +mcp = FastMCP(name="My Scalekit Protected Server", plugins=[auth_plugin]) @mcp.tool def protected_action() -> str: diff --git a/docs/integrations/supabase.mdx b/docs/integrations/supabase.mdx index 9ffda444d..ffd8b0e5b 100644 --- a/docs/integrations/supabase.mdx +++ b/docs/integrations/supabase.mdx @@ -19,7 +19,7 @@ Supabase Auth does not currently support [RFC 8707](https://www.rfc-editor.org/r Supabase's OAuth Server delegates the user consent screen to your application. When an MCP client initiates authorization, Supabase authenticates the user and then redirects to your application at a configured callback URL (e.g., `https://your-app.com/oauth/callback?authorization_id=...`). Your application must host a page that calls Supabase's `approveAuthorization()` or `denyAuthorization()` APIs to complete the flow. -`SupabaseProvider` handles the resource server side (token verification and metadata), but you are responsible for building and hosting the consent UI separately. See [Supabase's OAuth Server documentation](https://supabase.com/docs/guides/auth/oauth-server/getting-started) for details on implementing the authorization page. +The Supabase auth plugin handles the resource server side (token verification and metadata), but you are responsible for building and hosting the consent UI separately. See [Supabase's OAuth Server documentation](https://supabase.com/docs/guides/auth/oauth-server/getting-started) for details on implementing the authorization page. ## Configuration @@ -49,18 +49,20 @@ In your Supabase Dashboard: ### Step 3: FastMCP Configuration -Create your FastMCP server using the `SupabaseProvider`: +Create your FastMCP server using the `SupabaseAuth` plugin: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.supabase import SupabaseProvider +from fastmcp.server.plugins.auth.supabase import SupabaseAuth -auth = SupabaseProvider( - project_url="https://abc123.supabase.co", - base_url="http://localhost:8000", +auth_plugin = SupabaseAuth( + SupabaseAuth.Config( + project_url="https://abc123.supabase.co", + base_url="http://localhost:8000", + ) ) -mcp = FastMCP("Supabase Protected Server", auth=auth) +mcp = FastMCP("Supabase Protected Server", plugins=[auth_plugin]) @mcp.tool def protected_tool(message: str) -> str: @@ -112,12 +114,14 @@ For production deployments, load configuration from environment variables: ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.supabase import SupabaseProvider +from fastmcp.server.plugins.auth.supabase import SupabaseAuth -auth = SupabaseProvider( - project_url=os.environ["SUPABASE_PROJECT_URL"], - base_url=os.environ.get("BASE_URL", "https://your-server.com"), +auth_plugin = SupabaseAuth( + SupabaseAuth.Config( + project_url=os.environ["SUPABASE_PROJECT_URL"], + base_url=os.environ.get("BASE_URL", "https://your-server.com"), + ) ) -mcp = FastMCP(name="Supabase Secured App", auth=auth) +mcp = FastMCP(name="Supabase Secured App", plugins=[auth_plugin]) ``` diff --git a/docs/integrations/workos.mdx b/docs/integrations/workos.mdx index 4f13a5512..406f82e49 100644 --- a/docs/integrations/workos.mdx +++ b/docs/integrations/workos.mdx @@ -56,22 +56,24 @@ The callback URL must match exactly. The default path is `/auth/callback`, but y ### Step 2: FastMCP Configuration -Create your FastMCP server using the `WorkOSProvider`: +Create your FastMCP server using the `WorkOSAuth` plugin: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import WorkOSProvider +from fastmcp.server.plugins.auth.workos import WorkOSAuth # Configure WorkOS OAuth -auth = WorkOSProvider( - client_id="client_YOUR_CLIENT_ID", - client_secret="YOUR_CLIENT_SECRET", - authkit_domain="https://your-app.authkit.app", - base_url="http://localhost:8000", - required_scopes=["openid", "profile", "email"] +auth_plugin = WorkOSAuth( + WorkOSAuth.Config( + client_id="client_YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + authkit_domain="https://your-app.authkit.app", + base_url="http://localhost:8000", + required_scopes=["openid", "profile", "email"], + ) ) -mcp = FastMCP("WorkOS Protected Server", auth=auth) +mcp = FastMCP("WorkOS Protected Server", plugins=[auth_plugin]) @mcp.tool def protected_tool(message: str) -> str: @@ -134,21 +136,21 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import WorkOSProvider +from fastmcp.server.plugins.auth.workos import WorkOSAuth from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage -auth = WorkOSProvider( - client_id="client_YOUR_CLIENT_ID", - client_secret="YOUR_CLIENT_SECRET", - authkit_domain="https://your-app.authkit.app", - base_url="https://your-production-domain.com", - required_scopes=["openid", "profile", "email"], - - # Production token management - jwt_signing_key=os.environ["JWT_SIGNING_KEY"], +auth_plugin = WorkOSAuth( + WorkOSAuth.Config( + client_id="client_YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + authkit_domain="https://your-app.authkit.app", + base_url="https://your-production-domain.com", + required_scopes=["openid", "profile", "email"], + jwt_signing_key=os.environ["JWT_SIGNING_KEY"], + ), client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], @@ -158,7 +160,7 @@ auth = WorkOSProvider( ) ) -mcp = FastMCP(name="Production WorkOS App", auth=auth) +mcp = FastMCP(name="Production WorkOS App", plugins=[auth_plugin]) ``` @@ -197,4 +199,4 @@ OAuth callback path API request timeout - \ No newline at end of file + diff --git a/docs/servers/auth/authentication.mdx b/docs/servers/auth/authentication.mdx index d37c57f36..b4ce3e270 100644 --- a/docs/servers/auth/authentication.mdx +++ b/docs/servers/auth/authentication.mdx @@ -106,7 +106,7 @@ For example, the built-in `AuthKitProvider` uses WorkOS AuthKit, which fully sup ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider +from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider auth = AuthKitProvider( authkit_domain="https://your-project.authkit.app", @@ -136,7 +136,7 @@ For example, the built-in `GitHubProvider` extends `OAuthProxy` to work with Git ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider auth = GitHubProvider( client_id="Ov23li...", # Your GitHub OAuth App ID @@ -221,7 +221,7 @@ For production deployments, load sensitive values like client secrets from envir ```python import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider # Load secrets from environment variables auth = GitHubProvider( diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index aff1267ff..3a4f9a00e 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -355,7 +355,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore()) FastMCP includes pre-configured providers for common services: ```python -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider auth = GitHubProvider( client_id="your-github-app-id", @@ -690,7 +690,7 @@ For production deployments, load sensitive credentials from environment variable ```python import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider # Load secrets from environment variables auth = GitHubProvider( diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index d33cd611a..138f39bbe 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -218,7 +218,7 @@ auth = OIDCProxy( FastMCP includes pre-configured OIDC providers for common services: ```python -from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider auth = Auth0Provider( config_url="https://.../.well-known/openid-configuration", @@ -262,7 +262,7 @@ For production deployments, load sensitive credentials from environment variable ```python import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider # Load secrets from environment variables auth = Auth0Provider( diff --git a/docs/servers/auth/plugins.mdx b/docs/servers/auth/plugins.mdx index 0bd0f2c52..b5496f59c 100644 --- a/docs/servers/auth/plugins.mdx +++ b/docs/servers/auth/plugins.mdx @@ -10,68 +10,53 @@ Use an auth plugin when you want authentication to be configured alongside other ```python server.py from fastmcp import FastMCP -from fastmcp.server.plugins.auth import GitHubAuth +from fastmcp.server.plugins.auth.github import GitHubAuth mcp = FastMCP( "GitHub Protected Server", plugins=[ GitHubAuth( - { - "client_id": "your-github-client-id", - "client_secret": "your-github-client-secret", - "base_url": "https://your-server.com", - } + GitHubAuth.Config( + client_id="your-github-client-id", + client_secret="your-github-client-secret", + base_url="https://your-server.com", + ) ) ], ) ``` -The provider APIs remain available and are still the most direct option in Python code: - -```python -from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider - -auth = GitHubProvider( - client_id="your-github-client-id", - client_secret="your-github-client-secret", - base_url="https://your-server.com", -) - -mcp = FastMCP("GitHub Protected Server", auth=auth) -``` +Provider APIs remain available in each plugin's explicit `.provider` module for advanced direct auth wiring, but integrations should prefer the plugin form. ## Included Plugins -Import first-party auth plugins from `fastmcp.server.plugins.auth`: +Each first-party auth plugin lives in its own module under `fastmcp.server.plugins.auth`, mirroring the provider package: ```python -from fastmcp.server.plugins.auth import ( - Auth0Auth, - AuthKitAuth, - AWSCognitoAuth, - AzureAuth, - ClerkAuth, - DescopeAuth, - DiscordAuth, - GitHubAuth, - GoogleAuth, - KeycloakAuth, - OCIAuth, - PropelAuth, - ScalekitAuth, - SupabaseAuth, - WorkOSAuth, -) +from fastmcp.server.plugins.auth.auth0 import Auth0Auth +from fastmcp.server.plugins.auth.authkit import AuthKitAuth +from fastmcp.server.plugins.auth.aws import AWSCognitoAuth +from fastmcp.server.plugins.auth.azure import AzureAuth +from fastmcp.server.plugins.auth.clerk import ClerkAuth +from fastmcp.server.plugins.auth.descope import DescopeAuth +from fastmcp.server.plugins.auth.discord import DiscordAuth +from fastmcp.server.plugins.auth.github import GitHubAuth +from fastmcp.server.plugins.auth.google import GoogleAuth +from fastmcp.server.plugins.auth.keycloak import KeycloakAuth +from fastmcp.server.plugins.auth.oci import OCIAuth +from fastmcp.server.plugins.auth.propelauth import PropelAuth +from fastmcp.server.plugins.auth.scalekit import ScalekitAuth +from fastmcp.server.plugins.auth.supabase import SupabaseAuth +from fastmcp.server.plugins.auth.workos import WorkOSAuth ``` -Each plugin accepts a matching `*AuthConfig` model or a plain dictionary. Config fields mirror the wrapped provider's constructor wherever the value can be represented as JSON. Python-only objects such as custom token verifiers, HTTP clients, and client storage are passed as constructor keyword arguments: +Each plugin exposes its serializable configuration model as `Plugin.Config`. Config fields mirror the wrapped provider's constructor wherever the value can be represented as JSON. Python-only objects such as custom token verifiers, HTTP clients, and client storage are passed as constructor keyword arguments: ```python -from fastmcp.server.plugins.auth import SupabaseAuth, SupabaseAuthConfig +from fastmcp.server.plugins.auth.supabase import SupabaseAuth auth_plugin = SupabaseAuth( - SupabaseAuthConfig( + SupabaseAuth.Config( project_url="https://abc123.supabase.co", base_url="https://your-server.com", required_scopes=["read"], diff --git a/docs/servers/auth/remote-oauth.mdx b/docs/servers/auth/remote-oauth.mdx index 7c94a937f..75f90f0c8 100644 --- a/docs/servers/auth/remote-oauth.mdx +++ b/docs/servers/auth/remote-oauth.mdx @@ -197,7 +197,7 @@ WorkOS AuthKit provides an excellent example of remote OAuth integration. The `A ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider +from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider auth = AuthKitProvider( authkit_domain="https://your-project.authkit.app", diff --git a/docs/servers/storage-backends.mdx b/docs/servers/storage-backends.mdx index d13ce176d..b12c6dfb2 100644 --- a/docs/servers/storage-backends.mdx +++ b/docs/servers/storage-backends.mdx @@ -111,7 +111,7 @@ For OAuth token storage: ```python import os -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from key_value.aio.stores.redis import RedisStore auth = GitHubProvider( @@ -163,7 +163,7 @@ By default, FastMCP automatically manages keys and storage based on your platfor No configuration needed: ```python -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider auth = GitHubProvider( client_id="your-id", @@ -178,7 +178,7 @@ For production deployments, configure explicit keys and persistent network-acces ```python import os -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet diff --git a/docs/v2/deployment/http.mdx b/docs/v2/deployment/http.mdx index fa91c65e0..eb91f378f 100644 --- a/docs/v2/deployment/http.mdx +++ b/docs/v2/deployment/http.mdx @@ -468,7 +468,7 @@ When mounting an OAuth-protected server under a path prefix, declare your URLs u ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from starlette.applications import Starlette from starlette.routing import Mount @@ -530,7 +530,7 @@ Here's a complete working example showing all the pieces together: ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from starlette.applications import Starlette from starlette.routing import Mount import uvicorn diff --git a/docs/v2/integrations/auth0.mdx b/docs/v2/integrations/auth0.mdx index 9e5b186fc..fafd164e4 100644 --- a/docs/v2/integrations/auth0.mdx +++ b/docs/v2/integrations/auth0.mdx @@ -81,7 +81,7 @@ Create your FastMCP server using the `Auth0Provider`. ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider # The Auth0Provider utilizes Auth0 OIDC configuration auth_provider = Auth0Provider( @@ -158,7 +158,7 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet @@ -205,7 +205,7 @@ Setting this environment variable allows the Auth0 provider to be used automatic -Set to `fastmcp.server.auth.providers.auth0.Auth0Provider` to use Auth0 authentication. +Set to `fastmcp.server.plugins.auth.auth0.provider.Auth0Provider` to use Auth0 authentication. @@ -250,7 +250,7 @@ Comma-, space-, or JSON-separated list of required AUth0 scopes (e.g., `openid e Example `.env` file: ```bash # Use the Auth0 provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.auth0.Auth0Provider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.auth0.provider.Auth0Provider # Auth0 configuration and credentials FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL=https://.../.well-known/openid-configuration diff --git a/docs/v2/integrations/authkit.mdx b/docs/v2/integrations/authkit.mdx index aa7799a35..a87822991 100644 --- a/docs/v2/integrations/authkit.mdx +++ b/docs/v2/integrations/authkit.mdx @@ -43,7 +43,7 @@ Create your FastMCP server file and use the `AuthKitProvider` to handle all the ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider +from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider # The AuthKitProvider automatically discovers WorkOS endpoints # and configures JWT token validation @@ -90,7 +90,7 @@ Setting this environment variable allows the AuthKit provider to be used automat -Set to `fastmcp.server.auth.providers.workos.AuthKitProvider` to use AuthKit authentication. +Set to `fastmcp.server.plugins.auth.authkit.provider.AuthKitProvider` to use AuthKit authentication. @@ -115,7 +115,7 @@ Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid p Example `.env` file: ```bash # Use the AuthKit provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.AuthKitProvider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.authkit.provider.AuthKitProvider # AuthKit configuration FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_AUTHKIT_DOMAIN=https://your-project-12345.authkit.app diff --git a/docs/v2/integrations/aws-cognito.mdx b/docs/v2/integrations/aws-cognito.mdx index 4f39e1953..523d15ff2 100644 --- a/docs/v2/integrations/aws-cognito.mdx +++ b/docs/v2/integrations/aws-cognito.mdx @@ -121,7 +121,7 @@ Create your FastMCP server using the `AWSCognitoProvider`, which handles AWS Cog ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.aws import AWSCognitoProvider +from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider from fastmcp.server.dependencies import get_access_token # The AWSCognitoProvider handles JWT validation and user claims @@ -205,7 +205,7 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.aws import AWSCognitoProvider +from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet @@ -248,7 +248,7 @@ Setting this environment variable allows the AWS Cognito provider to be used aut -Set to `fastmcp.server.auth.providers.aws.AWSCognitoProvider` to use AWS Cognito authentication. +Set to `fastmcp.server.plugins.auth.aws.provider.AWSCognitoProvider` to use AWS Cognito authentication. @@ -293,7 +293,7 @@ Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid e Example `.env` file: ```bash # Use the AWS Cognito provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.aws.AWSCognitoProvider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.aws.provider.AWSCognitoProvider # AWS Cognito credentials FASTMCP_SERVER_AUTH_AWS_COGNITO_USER_POOL_ID=eu-central-1_XXXXXXXXX diff --git a/docs/v2/integrations/azure.mdx b/docs/v2/integrations/azure.mdx index 208b3f2ed..a0a33fab8 100644 --- a/docs/v2/integrations/azure.mdx +++ b/docs/v2/integrations/azure.mdx @@ -114,7 +114,7 @@ Create your FastMCP server using the `AzureProvider`, which handles Azure's OAut ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.plugins.auth.azure.provider import AzureProvider # The AzureProvider handles Azure's token format and validation auth_provider = AzureProvider( @@ -247,7 +247,7 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.azure import AzureProvider +from fastmcp.server.plugins.auth.azure.provider import AzureProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet @@ -292,7 +292,7 @@ Setting this environment variable allows the Azure provider to be used automatic -Set to `fastmcp.server.auth.providers.azure.AzureProvider` to use Azure authentication. +Set to `fastmcp.server.plugins.auth.azure.provider.AzureProvider` to use Azure authentication. @@ -360,7 +360,7 @@ This setting affects all Azure OAuth endpoints (authorization, token, issuer, JW Example `.env` file: ```bash # Use the Azure provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.azure.AzureProvider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.azure.provider.AzureProvider # Azure OAuth credentials FASTMCP_SERVER_AUTH_AZURE_CLIENT_ID=835f09b6-0f0f-40cc-85cb-f32c5829a149 diff --git a/docs/v2/integrations/descope.mdx b/docs/v2/integrations/descope.mdx index 14bade5f4..8193275d6 100644 --- a/docs/v2/integrations/descope.mdx +++ b/docs/v2/integrations/descope.mdx @@ -59,7 +59,7 @@ Create your FastMCP server file and use the DescopeProvider to handle all the OA ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.descope import DescopeProvider +from fastmcp.server.plugins.auth.descope.provider import DescopeProvider # The DescopeProvider automatically discovers Descope endpoints # and configures JWT token validation @@ -105,7 +105,7 @@ Setting this environment variable allows the Descope provider to be used automat - Set to `fastmcp.server.auth.providers.descope.DescopeProvider` to use + Set to `fastmcp.server.plugins.auth.descope.provider.DescopeProvider` to use Descope authentication. @@ -129,7 +129,7 @@ Example `.env` file: ```bash # Use the Descope provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.descope.DescopeProvider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.descope.provider.DescopeProvider # Descope configuration FASTMCP_SERVER_AUTH_DESCOPEPROVIDER_CONFIG_URL=https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration diff --git a/docs/v2/integrations/discord.mdx b/docs/v2/integrations/discord.mdx index b9154e3d6..0e9377d06 100644 --- a/docs/v2/integrations/discord.mdx +++ b/docs/v2/integrations/discord.mdx @@ -61,7 +61,7 @@ Create your FastMCP server using the `DiscordProvider`, which handles Discord's ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.discord import DiscordProvider +from fastmcp.server.plugins.auth.discord.provider import DiscordProvider auth_provider = DiscordProvider( client_id="12345", # Your Discord Application Client ID @@ -154,7 +154,7 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.discord import DiscordProvider +from fastmcp.server.plugins.auth.discord.provider import DiscordProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet @@ -193,7 +193,7 @@ Setting this environment variable allows the Discord provider to be used automat -Set to `fastmcp.server.auth.providers.discord.DiscordProvider` to use Discord authentication. +Set to `fastmcp.server.plugins.auth.discord.provider.DiscordProvider` to use Discord authentication. @@ -233,7 +233,7 @@ HTTP request timeout for Discord API calls Example `.env` file: ```bash -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.discord.DiscordProvider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.discord.provider.DiscordProvider FASTMCP_SERVER_AUTH_DISCORD_CLIENT_ID=12345 FASTMCP_SERVER_AUTH_DISCORD_CLIENT_SECRET=your-client-secret diff --git a/docs/v2/integrations/github.mdx b/docs/v2/integrations/github.mdx index 5c920063c..3a5cdef11 100644 --- a/docs/v2/integrations/github.mdx +++ b/docs/v2/integrations/github.mdx @@ -65,7 +65,7 @@ Create your FastMCP server using the `GitHubProvider`, which handles GitHub's OA ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider # The GitHubProvider handles GitHub's token format and validation auth_provider = GitHubProvider( @@ -144,7 +144,7 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet @@ -187,7 +187,7 @@ Setting this environment variable allows the GitHub provider to be used automati -Set to `fastmcp.server.auth.providers.github.GitHubProvider` to use GitHub authentication. +Set to `fastmcp.server.plugins.auth.github.provider.GitHubProvider` to use GitHub authentication. @@ -228,7 +228,7 @@ HTTP request timeout for GitHub API calls Example `.env` file: ```bash # Use the GitHub provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.github.provider.GitHubProvider # GitHub OAuth credentials FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID=Ov23liAbcDefGhiJkLmN diff --git a/docs/v2/integrations/google.mdx b/docs/v2/integrations/google.mdx index 3285765e1..853fd1a38 100644 --- a/docs/v2/integrations/google.mdx +++ b/docs/v2/integrations/google.mdx @@ -70,7 +70,7 @@ Create your FastMCP server using the `GoogleProvider`, which handles Google's OA ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.plugins.auth.google.provider import GoogleProvider # The GoogleProvider handles Google's token format and validation auth_provider = GoogleProvider( @@ -157,7 +157,7 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.plugins.auth.google.provider import GoogleProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet @@ -201,7 +201,7 @@ Setting this environment variable allows the Google provider to be used automati -Set to `fastmcp.server.auth.providers.google.GoogleProvider` to use Google authentication. +Set to `fastmcp.server.plugins.auth.google.provider.GoogleProvider` to use Google authentication. @@ -242,7 +242,7 @@ HTTP request timeout for Google API calls Example `.env` file: ```bash # Use the Google provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleProvider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.google.provider.GoogleProvider # Google OAuth credentials FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com diff --git a/docs/v2/integrations/oci.mdx b/docs/v2/integrations/oci.mdx index 282a51e17..426089a03 100644 --- a/docs/v2/integrations/oci.mdx +++ b/docs/v2/integrations/oci.mdx @@ -213,7 +213,7 @@ For production deployments with persistent token management across server restar import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.oci import OCIProvider +from fastmcp.server.plugins.auth.oci.provider import OCIProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper @@ -265,7 +265,7 @@ Setting this environment variable allows the OCI provider to be used automatical -Set to `fastmcp.server.auth.providers.oci.OCIProvider` to use OCI IAM authentication. +Set to `fastmcp.server.plugins.auth.oci.provider.OCIProvider` to use OCI IAM authentication. @@ -303,7 +303,7 @@ Redirect path configured in your OCI IAM Integrated Application Example `.env` file: ```bash # Use the OCI IAM provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.oci.OCIProvider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.oci.provider.OCIProvider # OCI IAM configuration and credentials FASTMCP_SERVER_AUTH_OCI_IAM_GUID=idcs-asaacasd1111..... diff --git a/docs/v2/integrations/scalekit.mdx b/docs/v2/integrations/scalekit.mdx index abe41a2ba..bb4470ea4 100644 --- a/docs/v2/integrations/scalekit.mdx +++ b/docs/v2/integrations/scalekit.mdx @@ -50,7 +50,7 @@ Create your FastMCP server file and use the ScalekitProvider to handle all the O ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.scalekit import ScalekitProvider +from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider # Discovers Scalekit endpoints and set up JWT token validation auth_provider = ScalekitProvider( @@ -95,7 +95,7 @@ Setting this environment variable allows the Scalekit provider to be used automa -Set to `fastmcp.server.auth.providers.scalekit.ScalekitProvider` to use Scalekit authentication. +Set to `fastmcp.server.plugins.auth.scalekit.provider.ScalekitProvider` to use Scalekit authentication. @@ -127,7 +127,7 @@ Example `.env`: ```bash # Use the Scalekit provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.scalekit.ScalekitProvider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.scalekit.provider.ScalekitProvider # Scalekit configuration FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_ENVIRONMENT_URL=https://your-env.scalekit.com diff --git a/docs/v2/integrations/supabase.mdx b/docs/v2/integrations/supabase.mdx index 94a57e0a2..8a4875df7 100644 --- a/docs/v2/integrations/supabase.mdx +++ b/docs/v2/integrations/supabase.mdx @@ -32,7 +32,7 @@ Create your FastMCP server using the `SupabaseProvider`: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.supabase import SupabaseProvider +from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider # Configure Supabase Auth auth = SupabaseProvider( @@ -101,7 +101,7 @@ Setting this environment variable allows the Supabase provider to be used automa -Set to `fastmcp.server.auth.providers.supabase.SupabaseProvider` to use Supabase authentication. +Set to `fastmcp.server.plugins.auth.supabase.provider.SupabaseProvider` to use Supabase authentication. @@ -130,7 +130,7 @@ Comma-, space-, or JSON-separated list of required OAuth scopes (e.g., `openid e Example `.env` file: ```bash # Use the Supabase provider -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.supabase.SupabaseProvider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.supabase.provider.SupabaseProvider # Supabase configuration FASTMCP_SERVER_AUTH_SUPABASE_PROJECT_URL=https://abc123.supabase.co diff --git a/docs/v2/integrations/workos.mdx b/docs/v2/integrations/workos.mdx index b8c01a1d6..0f317dcfe 100644 --- a/docs/v2/integrations/workos.mdx +++ b/docs/v2/integrations/workos.mdx @@ -61,7 +61,7 @@ Create your FastMCP server using the `WorkOSProvider`: ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import WorkOSProvider +from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider # Configure WorkOS OAuth auth = WorkOSProvider( @@ -135,7 +135,7 @@ For production deployments with persistent token management across server restar ```python server.py import os from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import WorkOSProvider +from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet @@ -180,7 +180,7 @@ Setting this environment variable allows the WorkOS provider to be used automati -Set to `fastmcp.server.auth.providers.workos.WorkOSProvider` to use WorkOS authentication. +Set to `fastmcp.server.plugins.auth.workos.provider.WorkOSProvider` to use WorkOS authentication. @@ -232,7 +232,7 @@ FASTMCP_SERVER_AUTH_WORKOS_BASE_URL=https://your-server.com FASTMCP_SERVER_AUTH_WORKOS_REQUIRED_SCOPES=["openid","profile","email"] # Optional: Automatically provision WorkOS auth for all servers -FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.WorkOSProvider +FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.workos.provider.WorkOSProvider ``` With environment variables set, you can either: @@ -240,14 +240,14 @@ With environment variables set, you can either: **Option 1: Manual instantiation (env vars provide defaults)** ```python server.py from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import WorkOSProvider +from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider # Env vars provide default values for WorkOSProvider() auth = WorkOSProvider() # Uses env var defaults mcp = FastMCP(name="WorkOS Protected Server", auth=auth) ``` -**Option 2: Automatic provisioning (requires FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.workos.WorkOSProvider)** +**Option 2: Automatic provisioning (requires FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.workos.provider.WorkOSProvider)** ```python server.py from fastmcp import FastMCP diff --git a/docs/v2/servers/auth/authentication.mdx b/docs/v2/servers/auth/authentication.mdx index c6b829bfe..8821760c5 100644 --- a/docs/v2/servers/auth/authentication.mdx +++ b/docs/v2/servers/auth/authentication.mdx @@ -106,7 +106,7 @@ For example, the built-in `AuthKitProvider` uses WorkOS AuthKit, which fully sup ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider +from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider auth = AuthKitProvider( authkit_domain="https://your-project.authkit.app", @@ -136,7 +136,7 @@ For example, the built-in `GitHubProvider` extends `OAuthProxy` to work with Git ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider auth = GitHubProvider( client_id="Ov23li...", # Your GitHub OAuth App ID @@ -202,11 +202,11 @@ Authentication providers are configured by specifying the full module path to th The full module path to the authentication provider class. Examples: -- `fastmcp.server.auth.providers.github.GitHubProvider` - GitHub OAuth -- `fastmcp.server.auth.providers.google.GoogleProvider` - Google OAuth +- `fastmcp.server.plugins.auth.github.provider.GitHubProvider` - GitHub OAuth +- `fastmcp.server.plugins.auth.google.provider.GoogleProvider` - Google OAuth - `fastmcp.server.auth.providers.jwt.JWTVerifier` - JWT token verification -- `fastmcp.server.auth.providers.workos.WorkOSProvider` - WorkOS OAuth -- `fastmcp.server.auth.providers.workos.AuthKitProvider` - WorkOS AuthKit +- `fastmcp.server.plugins.auth.workos.provider.WorkOSProvider` - WorkOS OAuth +- `fastmcp.server.plugins.auth.authkit.provider.AuthKitProvider` - WorkOS AuthKit - `mycompany.auth.CustomProvider` - Your custom provider class @@ -214,12 +214,12 @@ When using providers like GitHub or Google, you'll need to set provider-specific ```bash # GitHub OAuth -export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider +export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.github.provider.GitHubProvider export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="Ov23li..." export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="github_pat_..." # Google OAuth -export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleProvider +export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.google.provider.GoogleProvider export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID="123456.apps.googleusercontent.com" export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="GOCSPX-..." ``` diff --git a/docs/v2/servers/auth/oauth-proxy.mdx b/docs/v2/servers/auth/oauth-proxy.mdx index eef3bce1c..f82090a32 100644 --- a/docs/v2/servers/auth/oauth-proxy.mdx +++ b/docs/v2/servers/auth/oauth-proxy.mdx @@ -345,7 +345,7 @@ auth = OAuthProxy(..., client_storage=MemoryStore()) FastMCP includes pre-configured providers for common services: ```python -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider auth = GitHubProvider( client_id="your-github-app-id", @@ -590,7 +590,7 @@ For production deployments, configure the OAuth proxy through environment variab ```bash # Specify the provider implementation -export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider +export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.github.provider.GitHubProvider # Provider-specific credentials export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="Ov23li..." diff --git a/docs/v2/servers/auth/oidc-proxy.mdx b/docs/v2/servers/auth/oidc-proxy.mdx index 750298298..33f37db23 100644 --- a/docs/v2/servers/auth/oidc-proxy.mdx +++ b/docs/v2/servers/auth/oidc-proxy.mdx @@ -218,7 +218,7 @@ auth = OIDCProxy( FastMCP includes pre-configured OIDC providers for common services: ```python -from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider auth = Auth0Provider( config_url="https://.../.well-known/openid-configuration", @@ -247,7 +247,7 @@ For production deployments, configure the OIDC proxy through environment variabl ```bash # Specify the provider implementation -export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.auth0.Auth0Provider +export FASTMCP_SERVER_AUTH=fastmcp.server.plugins.auth.auth0.provider.Auth0Provider # Provider-specific credentials export FASTMCP_SERVER_AUTH_AUTH0_CONFIG_URL=https://.../.well-known/openid-configuration diff --git a/docs/v2/servers/auth/remote-oauth.mdx b/docs/v2/servers/auth/remote-oauth.mdx index 39c43c559..b1bd1be36 100644 --- a/docs/v2/servers/auth/remote-oauth.mdx +++ b/docs/v2/servers/auth/remote-oauth.mdx @@ -183,7 +183,7 @@ WorkOS AuthKit provides an excellent example of remote OAuth integration. The `A ```python from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider +from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider auth = AuthKitProvider( authkit_domain="https://your-project.authkit.app", diff --git a/docs/v2/servers/storage-backends.mdx b/docs/v2/servers/storage-backends.mdx index 25b8580b0..2cfd08321 100644 --- a/docs/v2/servers/storage-backends.mdx +++ b/docs/v2/servers/storage-backends.mdx @@ -57,7 +57,7 @@ middleware = ResponseCachingMiddleware( Or with OAuth token storage: ```python -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from key_value.aio.stores.disk import DiskStore auth = GitHubProvider( @@ -110,7 +110,7 @@ For OAuth token storage: ```python import os -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from key_value.aio.stores.redis import RedisStore auth = GitHubProvider( @@ -162,7 +162,7 @@ By default, FastMCP automatically manages keys and storage based on your platfor No configuration needed: ```python -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider auth = GitHubProvider( client_id="your-id", @@ -177,7 +177,7 @@ For production deployments, configure explicit keys and persistent network-acces ```python import os -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 28c50d060..ab716d3bb 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -1,135 +1,20 @@ -"""Auth0 OAuth provider for FastMCP. +"""Backward compatibility shim for Auth0 auth provider.""" -This module provides a complete Auth0 integration that's ready to use with -just the configuration URL, client ID, client secret, audience, and base URL. +from __future__ import annotations -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.auth0 import Auth0Provider +import warnings - # Simple Auth0 OAuth protection - auth = Auth0Provider( - config_url="https://auth0.config.url", - client_id="your-auth0-client-id", - client_secret="your-auth0-client-secret", - audience="your-auth0-api-audience", - base_url="http://localhost:8000", +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning + +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.auth0 is deprecated. " + "Import from fastmcp.server.plugins.auth.auth0.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, ) - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" +from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider -from typing import Literal - -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl - -from fastmcp.server.auth.oidc_proxy import OIDCProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class Auth0Provider(OIDCProxy): - """An Auth0 provider implementation for FastMCP. - - This provider is a complete Auth0 integration that's ready to use with - just the configuration URL, client ID, client secret, audience, and base URL. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.auth0 import Auth0Provider - - # Simple Auth0 OAuth protection - auth = Auth0Provider( - config_url="https://auth0.config.url", - client_id="your-auth0-client-id", - client_secret="your-auth0-client-secret", - audience="your-auth0-api-audience", - base_url="http://localhost:8000", - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` - """ - - def __init__( - self, - *, - config_url: AnyHttpUrl | str, - client_id: str, - client_secret: str, - audience: str, - base_url: AnyHttpUrl | str, - resource_base_url: AnyHttpUrl | str | None = None, - issuer_url: AnyHttpUrl | str | None = None, - required_scopes: list[str] | None = None, - redirect_path: str | None = None, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["remember", "external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - ) -> None: - """Initialize Auth0 OAuth provider. - - Args: - config_url: Auth0 config URL - client_id: Auth0 application client id - client_secret: Auth0 application client secret - audience: Auth0 API audience - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - resource_base_url: Optional public base URL for the protected resource metadata - and token audience. Defaults to ``base_url``. - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - required_scopes: Required Auth0 scopes (defaults to ["openid"]) - redirect_path: Redirect path configured in Auth0 application - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to Auth0. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - """ - # Parse scopes if provided as string - auth0_required_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else ["openid"] - ) - - super().__init__( - config_url=config_url, - client_id=client_id, - client_secret=client_secret, - audience=audience, - base_url=base_url, - resource_base_url=resource_base_url, - issuer_url=issuer_url, - redirect_path=redirect_path, - required_scopes=auth0_required_scopes, - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - ) - - logger.debug( - "Initialized Auth0 OAuth provider for client %s with scopes: %s", - client_id, - auth0_required_scopes, - ) +__all__ = ["Auth0Provider"] diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index 6837dc5b5..5a3b63d23 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -1,229 +1,23 @@ -"""AWS Cognito OAuth provider for FastMCP. - -This module provides a complete AWS Cognito OAuth integration that's ready to use -with a user pool ID, domain prefix, client ID and client secret. It handles all -the complexity of AWS Cognito's OAuth flow, token validation, and user management. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider - - # Simple AWS Cognito OAuth protection - auth = AWSCognitoProvider( - user_pool_id="your-user-pool-id", - aws_region="eu-central-1", - client_id="your-cognito-client-id", - client_secret="your-cognito-client-secret" - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" +"""Backward compatibility shim for AWS Cognito auth provider.""" from __future__ import annotations -from typing import Literal +import warnings -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oidc_proxy import OIDCProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.aws is deprecated. " + "Import from fastmcp.server.plugins.auth.aws.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) -logger = get_logger(__name__) +from fastmcp.server.plugins.auth.aws.provider import ( + AWSCognitoProvider, + AWSCognitoTokenVerifier, +) - -class AWSCognitoTokenVerifier(JWTVerifier): - """Token verifier for Cognito access tokens. - - Cognito access tokens use a ``client_id`` claim instead of the - standard ``aud`` claim. This subclass passes ``audience=None`` - to the parent (skipping the ``aud`` check) and validates the - ``client_id`` claim directly. - """ - - def __init__(self, *, audience: str | list[str] | None = None, **kwargs): - self._expected_client_id = audience - super().__init__(audience=None, **kwargs) - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token and filter claims to Cognito-specific subset.""" - access_token = await super().verify_token(token) - if not access_token: - return None - - # Validate client_id claim (Cognito's equivalent of aud) - if self._expected_client_id: - token_client_id = access_token.claims.get("client_id") - if isinstance(self._expected_client_id, list): - valid = token_client_id in self._expected_client_id - else: - valid = token_client_id == self._expected_client_id - if not valid: - self.logger.debug( - "Token validation failed: client_id mismatch (expected %s, got %s)", - self._expected_client_id, - token_client_id, - ) - return None - - # Filter claims to Cognito-specific subset - cognito_claims = { - "sub": access_token.claims.get("sub"), - "username": access_token.claims.get("username"), - "cognito:groups": access_token.claims.get("cognito:groups", []), - } - - return AccessToken( - token=access_token.token, - client_id=access_token.client_id, - scopes=access_token.scopes, - expires_at=access_token.expires_at, - claims=cognito_claims, - ) - - -class AWSCognitoProvider(OIDCProxy): - """Complete AWS Cognito OAuth provider for FastMCP. - - This provider makes it trivial to add AWS Cognito OAuth protection to any - FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details, - client credentials, and a base URL, and you're ready to go. - - Features: - - Automatic OIDC Discovery from AWS Cognito User Pool - - Automatic JWT token validation via Cognito's public keys - - Cognito-specific claim filtering (sub, username, cognito:groups) - - Support for Cognito User Pools - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider - - auth = AWSCognitoProvider( - user_pool_id="eu-central-1_XXXXXXXXX", - aws_region="eu-central-1", - client_id="your-cognito-client-id", - client_secret="your-cognito-client-secret", - base_url="https://my-server.com", - redirect_path="/custom/callback", - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - user_pool_id: str, - client_id: str, - client_secret: str, - base_url: AnyHttpUrl | str, - resource_base_url: AnyHttpUrl | str | None = None, - aws_region: str = "eu-central-1", - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str = "/auth/callback", - required_scopes: list[str] | None = None, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["remember", "external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - ): - """Initialize AWS Cognito OAuth provider. - - Args: - user_pool_id: Your Cognito User Pool ID (e.g., "eu-central-1_XXXXXXXXX") - client_id: Cognito app client ID - client_secret: Cognito app client secret - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - resource_base_url: Optional public base URL for the protected resource metadata - and token audience. Defaults to ``base_url``. - aws_region: AWS region where your User Pool is located (defaults to "eu-central-1") - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in Cognito app (defaults to "/auth/callback") - required_scopes: Required Cognito scopes (defaults to ["openid"]) - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to AWS Cognito. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - """ - # Parse scopes if provided as string - required_scopes_final = ( - parse_scopes(required_scopes) if required_scopes is not None else ["openid"] - ) - - # Construct OIDC discovery URL - config_url = f"https://cognito-idp.{aws_region}.amazonaws.com/{user_pool_id}/.well-known/openid-configuration" - - # Store Cognito-specific info for claim filtering - self.user_pool_id = user_pool_id - self.aws_region = aws_region - self.client_id = client_id - - # Initialize OIDC proxy with Cognito discovery - super().__init__( - config_url=config_url, - client_id=client_id, - client_secret=client_secret, - algorithm="RS256", - required_scopes=required_scopes_final, - base_url=base_url, - resource_base_url=resource_base_url, - issuer_url=issuer_url, - redirect_path=redirect_path, - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - ) - - logger.debug( - "Initialized AWS Cognito OAuth provider for client %s with scopes: %s", - client_id, - required_scopes_final, - ) - - def get_token_verifier( - self, - *, - algorithm: str | None = None, - audience: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int | None = None, - ) -> AWSCognitoTokenVerifier: - """Creates a Cognito-specific token verifier with claim filtering. - - Args: - algorithm: Optional token verifier algorithm - audience: Optional token verifier audience - required_scopes: Optional token verifier required_scopes - timeout_seconds: HTTP request timeout in seconds - """ - return AWSCognitoTokenVerifier( - issuer=str(self.oidc_config.issuer), - audience=audience or self.client_id, - algorithm=algorithm, - jwks_uri=str(self.oidc_config.jwks_uri), - required_scopes=required_scopes, - ) +__all__ = ["AWSCognitoProvider", "AWSCognitoTokenVerifier"] diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 0f0763b97..a4f1d0dab 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -1,768 +1,24 @@ -"""Azure (Microsoft Entra) OAuth provider for FastMCP. - -This provider implements Azure/Microsoft Entra ID OAuth authentication -using the OAuth Proxy pattern for non-DCR OAuth flows. -""" +"""Backward compatibility shim for Azure auth provider.""" from __future__ import annotations -import hashlib -from collections import OrderedDict -from typing import TYPE_CHECKING, Any, Literal, cast - -import httpx -from key_value.aio.protocols import AsyncKeyValue - -from fastmcp.dependencies import Dependency -from fastmcp.server.auth.auth import MultiAuth -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from azure.identity.aio import OnBehalfOfCredential - from mcp.server.auth.provider import AuthorizationParams - from mcp.shared.auth import OAuthClientInformationFull - from pydantic import AnyHttpUrl - - from fastmcp.server.auth.auth import AuthProvider - -logger = get_logger(__name__) - -# Standard OIDC scopes that should never be prefixed with identifier_uri. -# Per Microsoft docs: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc -# "OIDC scopes are requested as simple string identifiers without resource prefixes" -OIDC_SCOPES = frozenset({"openid", "profile", "email", "offline_access"}) - - -class AzureProvider(OAuthProxy): - """Azure (Microsoft Entra) OAuth provider for FastMCP. - - This provider implements Azure/Microsoft Entra ID authentication using the - OAuth Proxy pattern. It supports both organizational accounts and personal - Microsoft accounts depending on the tenant configuration. - - Scope Handling: - - required_scopes: Provide unprefixed scope names (e.g., ["read", "write"]) - → Automatically prefixed with identifier_uri during initialization - → Validated on all tokens and advertised to MCP clients - - additional_authorize_scopes: Provide full format (e.g., ["User.Read"]) - → NOT prefixed, NOT validated, NOT advertised to clients - → Used to request Microsoft Graph or other upstream API permissions - - Features: - - OAuth proxy to Azure/Microsoft identity platform - - JWT validation using tenant issuer and JWKS - - Supports tenant configurations: specific tenant ID, "organizations", or "consumers" - - Custom API scopes and Microsoft Graph scopes in a single provider - - Setup: - 1. Create an App registration in Azure Portal - 2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path) - 3. Add an Application ID URI under "Expose an API" (defaults to api://{client_id}) - 4. Add custom scopes (e.g., "read", "write") under "Expose an API" - 5. Set access token version to 2 in the App manifest: "requestedAccessTokenVersion": 2 - 6. Create a client secret - 7. Get Application (client) ID, Directory (tenant) ID, and client secret - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.azure import AzureProvider - - # Standard Azure (Public Cloud) - auth = AzureProvider( - client_id="your-client-id", - client_secret="your-client-secret", - tenant_id="your-tenant-id", - required_scopes=["read", "write"], # Unprefixed scope names - additional_authorize_scopes=["User.Read", "Mail.Read"], # Optional Graph scopes - base_url="http://localhost:8000", - # identifier_uri defaults to api://{client_id} - ) - - # Azure Government - auth_gov = AzureProvider( - client_id="your-client-id", - client_secret="your-client-secret", - tenant_id="your-tenant-id", - required_scopes=["read", "write"], - base_authority="login.microsoftonline.us", # Override for Azure Gov - base_url="http://localhost:8000", - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - client_id: str, - client_secret: str | None = None, - tenant_id: str, - required_scopes: list[str], - base_url: str, - resource_base_url: AnyHttpUrl | str | None = None, - identifier_uri: str | None = None, - issuer_url: str | None = None, - redirect_path: str | None = None, - additional_authorize_scopes: list[str] | None = None, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["remember", "external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - base_authority: str = "login.microsoftonline.com", - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ) -> None: - """Initialize Azure OAuth provider. - - Args: - client_id: Azure application (client) ID from your App registration - client_secret: Azure client secret from your App registration. Optional when - using alternative credentials (e.g., managed identity with a custom - _create_upstream_oauth_client override). When omitted, jwt_signing_key - must be provided. - tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers") - identifier_uri: Optional Application ID URI for your custom API (defaults to api://{client_id}). - This URI is automatically prefixed to all required_scopes during initialization. - Example: identifier_uri="api://my-api" + required_scopes=["read"] - → tokens validated for "api://my-api/read" - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - resource_base_url: Optional public base URL for the protected resource metadata - and token audience. Defaults to ``base_url``. - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback") - base_authority: Azure authority base URL (defaults to "login.microsoftonline.com"). - For Azure Government, use "login.microsoftonline.us". - required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]). - - Automatically prefixed with identifier_uri during initialization - - Validated on all tokens - - Advertised in Protected Resource Metadata - - Must match scope names defined in Azure Portal under "Expose an API" - Example: ["read", "write"] → validates tokens containing ["api://xxx/read", "api://xxx/write"] - additional_authorize_scopes: Microsoft Graph or other upstream scopes in full format. - - NOT prefixed with identifier_uri - - NOT validated on tokens - - NOT advertised to MCP clients - - Used to request additional permissions from Azure (e.g., Graph API access) - Example: ["User.Read", "Mail.Read"] - These scopes allow your FastMCP server to call Microsoft Graph APIs using the - upstream Azure token, but MCP clients are unaware of them. - Note: "offline_access" is automatically included to obtain refresh tokens. - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to Azure. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches. - When provided, the client is reused for JWT key fetches and the caller - is responsible for its lifecycle. When None (default), a fresh client is created per fetch. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - # Parse scopes if provided as string - parsed_required_scopes = parse_scopes(required_scopes) - parsed_additional_scopes: list[str] = ( - parse_scopes(additional_authorize_scopes) or [] - if additional_authorize_scopes - else [] - ) - - # Always include offline_access to get refresh tokens from Azure - if "offline_access" not in parsed_additional_scopes: - parsed_additional_scopes = [*parsed_additional_scopes, "offline_access"] - - # Store Azure-specific config for OBO credential creation - self._tenant_id = tenant_id - self._base_authority = base_authority - - # Cache of OBO credentials keyed by hash of user assertion token. - # Reusing credentials allows the Azure SDK's internal token cache - # to avoid redundant OBO exchanges for the same user + scopes. - self._obo_credentials: OrderedDict[str, OnBehalfOfCredential] = OrderedDict() - self._obo_max_credentials: int = 128 - - # Apply defaults - self.identifier_uri = identifier_uri or f"api://{client_id}" - self.additional_authorize_scopes: list[str] = parsed_additional_scopes - - # Always validate tokens against the app's API client ID using JWT - issuer = f"https://{base_authority}/{tenant_id}/v2.0" - jwks_uri = f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys" - - # Azure access tokens only include custom API scopes in the `scp` claim, - # NOT standard OIDC scopes (openid, profile, email, offline_access). - # Filter out OIDC scopes from validation - they'll still be sent to Azure - # during authorization (handled by _prefix_scopes_for_azure). - validation_scopes = [ - s for s in (parsed_required_scopes or []) if s not in OIDC_SCOPES - ] - if not validation_scopes: - raise ValueError( - "AzureProvider requires at least one non-OIDC scope in " - "required_scopes (e.g., 'read', 'write'). OIDC scopes like " - "'openid', 'profile', 'email', and 'offline_access' are not " - "included in Azure access token claims and cannot be used for " - "scope enforcement." - ) - - token_verifier = JWTVerifier( - jwks_uri=jwks_uri, - issuer=issuer, - audience=[client_id, self.identifier_uri], - algorithm="RS256", - required_scopes=validation_scopes, # Only validate non-OIDC scopes - http_client=http_client, - ) - - # Build Azure OAuth endpoints with tenant - authorization_endpoint = ( - f"https://{base_authority}/{tenant_id}/oauth2/v2.0/authorize" - ) - token_endpoint = f"https://{base_authority}/{tenant_id}/oauth2/v2.0/token" - - # Initialize OAuth proxy with Azure endpoints - # Remember there's hooks called, such as _prepare_scopes_for_token_exchange - # and _prepare_scopes_for_upstream_refresh - super().__init__( - upstream_authorization_endpoint=authorization_endpoint, - upstream_token_endpoint=token_endpoint, - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - resource_base_url=resource_base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, # Default to base_url if not specified - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - valid_scopes=parsed_required_scopes, - enable_cimd=enable_cimd, - ) - - authority_info = "" - if base_authority != "login.microsoftonline.com": - authority_info = f" using authority {base_authority}" - logger.info( - "Initialized Azure OAuth provider for client %s with tenant %s%s%s", - client_id, - tenant_id, - f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "", - authority_info, - ) - - async def authorize( - self, - client: OAuthClientInformationFull, - params: AuthorizationParams, - ) -> str: - """Start OAuth transaction and redirect to Azure AD. - - Override parent's authorize method to filter out the 'resource' parameter - which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use - scopes to determine the resource/audience instead of a separate parameter. - - Args: - client: OAuth client information - params: Authorization parameters from the client - - Returns: - Authorization URL to redirect the user to Azure AD - """ - # Clear the resource parameter that Azure AD v2.0 doesn't support - # This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators) - # but Azure AD v2.0 uses scopes instead to determine the audience - params_to_use = params - if hasattr(params, "resource"): - original_resource = getattr(params, "resource", None) - if original_resource is not None: - params_to_use = params.model_copy(update={"resource": None}) - if original_resource: - logger.debug( - "Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)", - original_resource, - ) - # Don't modify the scopes in params - they stay unprefixed for MCP clients - # We'll prefix them when building the Azure authorization URL (in _build_upstream_authorize_url) - auth_url = await super().authorize(client, params_to_use) - separator = "&" if "?" in auth_url else "?" - return f"{auth_url}{separator}prompt=select_account" - - def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]: - """Prefix unprefixed custom API scopes with identifier_uri for Azure. - - This helper centralizes the scope prefixing logic used in both - authorization and token refresh flows. - - Scopes that are NOT prefixed: - - Standard OIDC scopes (openid, profile, email, offline_access) - - Fully-qualified URIs (contain "://") - - Scopes with path component (contain "/") - - Note: Microsoft Graph scopes (e.g., User.Read) should be passed via - `additional_authorize_scopes` or use fully-qualified format - (e.g., https://graph.microsoft.com/User.Read). - - Args: - scopes: List of scopes, may be prefixed or unprefixed - - Returns: - List of scopes with identifier_uri prefix applied where needed - """ - prefixed = [] - for scope in scopes: - if scope in OIDC_SCOPES: - # Standard OIDC scopes - never prefix - prefixed.append(scope) - elif "://" in scope or "/" in scope: - # Already fully-qualified (e.g., "api://xxx/read" or - # "https://graph.microsoft.com/User.Read") - prefixed.append(scope) - else: - # Unprefixed custom API scope - prefix with identifier_uri - prefixed.append(f"{self.identifier_uri}/{scope}") - return prefixed - - def _build_upstream_authorize_url( - self, txn_id: str, transaction: dict[str, Any] - ) -> str: - """Build Azure authorization URL with prefixed scopes. - - Overrides parent to prefix scopes with identifier_uri before sending to Azure, - while keeping unprefixed scopes in the transaction for MCP clients. - """ - # Get unprefixed scopes from transaction - unprefixed_scopes = transaction.get("scopes") or self.required_scopes or [] - - # Prefix scopes for Azure authorization request - prefixed_scopes = self._prefix_scopes_for_azure(unprefixed_scopes) - - # Add Microsoft Graph scopes (not validated, not prefixed) - if self.additional_authorize_scopes: - prefixed_scopes.extend(self.additional_authorize_scopes) - - # Temporarily modify transaction dict for parent's URL building - modified_transaction = transaction.copy() - modified_transaction["scopes"] = prefixed_scopes - - # Let parent build the URL with prefixed scopes - return super()._build_upstream_authorize_url(txn_id, modified_transaction) - - def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]: - """Prepare scopes for Azure authorization code exchange. - - Azure requires scopes during token exchange (AADSTS28003 error if missing). - Azure only allows ONE resource per token request (AADSTS28000), so we only - include scopes for this API plus OIDC scopes. - - Args: - scopes: Scopes from the authorization request (unprefixed) - - Returns: - List of scopes for Azure token endpoint - """ - # Prefix scopes for this API - prefixed_scopes = self._prefix_scopes_for_azure(scopes or []) - - # Add OIDC scopes only (not other API scopes) to avoid AADSTS28000 - if self.additional_authorize_scopes: - prefixed_scopes.extend( - s for s in self.additional_authorize_scopes if s in OIDC_SCOPES - ) - - deduplicated = list(dict.fromkeys(prefixed_scopes)) - logger.debug("Token exchange scopes: %s", deduplicated) - return deduplicated - - def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]: - """Prepare scopes for Azure token refresh. - - Azure requires fully-qualified scopes and only allows ONE resource per - token request (AADSTS28000). We include scopes for this API plus OIDC scopes. - - Args: - scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"]) - - Returns: - Deduplicated list of scopes formatted for Azure token endpoint - """ - logger.debug("Base scopes from storage: %s", scopes) - - # Filter out any additional_authorize_scopes that may have been stored - additional_scopes_set = set(self.additional_authorize_scopes or []) - base_scopes = [s for s in scopes if s not in additional_scopes_set] - - # Prefix base scopes with identifier_uri for Azure - prefixed_scopes = self._prefix_scopes_for_azure(base_scopes) - - # Add OIDC scopes only (not other API scopes) to avoid AADSTS28000 - if self.additional_authorize_scopes: - prefixed_scopes.extend( - s for s in self.additional_authorize_scopes if s in OIDC_SCOPES - ) - - deduplicated_scopes = list(dict.fromkeys(prefixed_scopes)) - logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes) - return deduplicated_scopes - - async def _extract_upstream_claims( - self, idp_tokens: dict[str, Any] - ) -> dict[str, Any] | None: - """Extract claims from Azure token response to embed in FastMCP JWT. - - Decodes the Azure access token (which is a JWT) to extract user identity - claims. This allows gateways to inspect upstream identity information by - decoding the FastMCP JWT without needing server-side storage lookups. - - Azure access tokens contain claims like: - - sub: Subject identifier (unique per user per application) - - oid: Object ID (unique user identifier across Azure AD) - - tid: Tenant ID - - azp: Authorized party (client ID that requested the token) - - name: Display name - - given_name: First name - - family_name: Last name - - preferred_username: User principal name (email format) - - upn: User Principal Name - - email: Email address (if available) - - roles: Application roles assigned to the user - - groups: Group memberships (if configured) - - Args: - idp_tokens: Full token response from Azure, containing access_token - and potentially id_token. - - Returns: - Dict of extracted claims, or None if extraction fails. - """ - access_token = idp_tokens.get("access_token") - if not access_token: - return None - - try: - # Azure access tokens are JWTs - decode without verification - # (already validated by token_verifier during token exchange) - payload = decode_jwt_payload(access_token) - - # Extract useful identity claims - claims: dict[str, Any] = {} - claim_keys = [ - "sub", - "oid", - "tid", - "azp", - "name", - "given_name", - "family_name", - "preferred_username", - "upn", - "email", - "roles", - "groups", - ] - for claim in claim_keys: - if claim in payload: - claims[claim] = payload[claim] - - if claims: - logger.debug( - "Extracted %d Azure claims for embedding in FastMCP JWT", - len(claims), - ) - return claims - - return None - - except Exception as e: - logger.debug("Failed to extract Azure claims: %s", e) - return None - - async def get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential: - """Get a cached or new OnBehalfOfCredential for OBO token exchange. - - Credentials are cached by user assertion so the Azure SDK's internal - token cache can avoid redundant OBO exchanges when the same user - calls multiple tools with the same scopes. - - Args: - user_assertion: The user's access token to exchange via OBO. - - Returns: - A configured OnBehalfOfCredential ready for get_token() calls. - - Raises: - ImportError: If azure-identity is not installed (requires fastmcp[azure]). - """ - _require_azure_identity("OBO token exchange") - from azure.identity.aio import OnBehalfOfCredential - - key = hashlib.sha256(user_assertion.encode()).hexdigest() - - if key in self._obo_credentials: - self._obo_credentials.move_to_end(key) - return self._obo_credentials[key] - - obo_kwargs: dict[str, Any] = { - "tenant_id": self._tenant_id, - "client_id": self._upstream_client_id, - "user_assertion": user_assertion, - "authority": f"https://{self._base_authority}", - } - if self._upstream_client_secret is not None: - obo_kwargs["client_secret"] = ( - self._upstream_client_secret.get_secret_value() - ) - else: - raise ValueError( - "OBO token exchange requires either a client_secret or a subclass " - "that overrides get_obo_credential() to provide alternative credentials " - "(e.g., client_assertion_func for managed identity)." - ) - credential = OnBehalfOfCredential(**obo_kwargs) - self._obo_credentials[key] = credential - - # Evict oldest if over capacity - while len(self._obo_credentials) > self._obo_max_credentials: - _, evicted = self._obo_credentials.popitem(last=False) - await evicted.close() - - return credential - - async def close_obo_credentials(self) -> None: - """Close all cached OBO credentials.""" - credentials = list(self._obo_credentials.values()) - self._obo_credentials.clear() - for credential in credentials: - try: - await credential.close() - except Exception: - logger.debug("Error closing OBO credential", exc_info=True) - - -class AzureJWTVerifier(JWTVerifier): - """JWT verifier pre-configured for Azure AD / Microsoft Entra ID. - - Auto-configures JWKS URI, issuer, audience, and scope handling from your - Azure app registration details. Designed for Managed Identity and other - token-verification-only scenarios where AzureProvider's full OAuth proxy - isn't needed. - - Handles Azure's scope format automatically: - - Validates tokens using short-form scopes (what Azure puts in ``scp`` claims) - - Advertises full-URI scopes in OAuth metadata (what clients need to request) - - Example:: - - from fastmcp.server.auth import RemoteAuthProvider - from fastmcp.server.auth.providers.azure import AzureJWTVerifier - from pydantic import AnyHttpUrl - - verifier = AzureJWTVerifier( - client_id="your-client-id", - tenant_id="your-tenant-id", - required_scopes=["access_as_user"], - ) - - auth = RemoteAuthProvider( - token_verifier=verifier, - authorization_servers=[ - AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0") - ], - base_url="https://my-server.com", - ) - """ - - def __init__( - self, - *, - client_id: str, - tenant_id: str, - required_scopes: list[str] | None = None, - identifier_uri: str | None = None, - base_authority: str = "login.microsoftonline.com", - ): - """Initialize Azure JWT verifier. - - Args: - client_id: Azure application (client) ID from your App registration - tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers"). - For multi-tenant apps ("organizations" or "consumers"), issuer validation - is skipped since Azure tokens carry the actual tenant GUID as issuer. - required_scopes: Scope names as they appear in Azure Portal under "Expose an API" - (e.g., ["access_as_user", "read"]). These are validated against - the short-form scopes in token ``scp`` claims, and automatically - prefixed with identifier_uri for OAuth metadata. - identifier_uri: Application ID URI (defaults to ``api://{client_id}``). - Used to prefix scopes in OAuth metadata so clients know the full - scope URIs to request from Azure. - base_authority: Azure authority base URL (defaults to "login.microsoftonline.com"). - For Azure Government, use "login.microsoftonline.us". - """ - self._identifier_uri = identifier_uri or f"api://{client_id}" - - # For multi-tenant apps, Azure tokens carry the actual tenant GUID as - # issuer, not the literal "organizations" or "consumers" string. Skip - # issuer validation for these — audience still protects against wrong-app tokens. - multi_tenant_values = {"organizations", "consumers", "common"} - issuer: str | None = ( - None - if tenant_id in multi_tenant_values - else f"https://{base_authority}/{tenant_id}/v2.0" - ) - - super().__init__( - jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys", - issuer=issuer, - audience=[client_id, self._identifier_uri], - algorithm="RS256", - required_scopes=required_scopes, - ) - - @property - def scopes_supported(self) -> list[str]: - """Return scopes with Azure URI prefix for OAuth metadata. - - Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp`` - claim, but clients must request full URI scopes (e.g., - ``api://client-id/read``) from the Azure authorization endpoint. This - property returns the full-URI form for OAuth metadata while - ``required_scopes`` retains the short form for token validation. - """ - if not self.required_scopes: - return [] - prefixed = [] - for scope in self.required_scopes: - if scope in OIDC_SCOPES or "://" in scope or "/" in scope: - prefixed.append(scope) - else: - prefixed.append(f"{self._identifier_uri}/{scope}") - return prefixed - - -# --- Dependency injection support --- -# These require fastmcp[azure] extra for azure-identity - - -def _require_azure_identity(feature: str) -> None: - """Raise ImportError with install instructions if azure-identity is not available.""" - try: - import azure.identity # noqa: F401 - except ImportError as e: - raise ImportError( - f"{feature} requires the `azure` extra. " - "Install with: pip install 'fastmcp[azure]'" - ) from e - - -def _find_azure_provider(auth: AuthProvider | None) -> AzureProvider | None: - """Extract an AzureProvider from an auth provider, unwrapping MultiAuth if needed.""" - if isinstance(auth, AzureProvider): - return auth - - if isinstance(auth, MultiAuth) and isinstance(auth.server, AzureProvider): - return auth.server - - return None - - -class _EntraOBOToken(Dependency[str]): - """Dependency that performs OBO token exchange for Microsoft Entra. - - Uses azure.identity's OnBehalfOfCredential for async-native OBO, - with automatic token caching and refresh. Credentials are cached on - the AzureProvider so repeated tool calls reuse existing credentials - and benefit from the Azure SDK's internal token cache. - """ - - def __init__(self, scopes: list[str]): - self.scopes = scopes - - async def __aenter__(self) -> str: - _require_azure_identity("EntraOBOToken") - - from fastmcp.server.dependencies import get_access_token, get_server - - access_token = get_access_token() - if access_token is None: - raise RuntimeError( - "No access token available. Cannot perform OBO exchange." - ) - - server = get_server() - azure_provider = _find_azure_provider(server.auth) - if azure_provider is None: - raise RuntimeError( - "EntraOBOToken requires an AzureProvider as the auth provider. " - f"Current provider: {type(server.auth).__name__}" - ) - - credential = await azure_provider.get_obo_credential( - user_assertion=access_token.token, - ) - - result = await credential.get_token(*self.scopes) - return result.token - - -def EntraOBOToken(scopes: list[str]) -> str: - """Exchange the user's Entra token for a downstream API token via OBO. - - This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange, - allowing your MCP server to call downstream APIs (like Microsoft Graph) on - behalf of the authenticated user. - - Args: - scopes: The scopes to request for the downstream API. For Microsoft Graph, - use scopes like ["https://graph.microsoft.com/Mail.Read"] or - ["https://graph.microsoft.com/.default"]. - - Returns: - A dependency that resolves to the downstream API access token string - - Raises: - ImportError: If fastmcp[azure] is not installed - RuntimeError: If no access token is available, provider is not Azure, - or OBO exchange fails - - Example: - ```python - from fastmcp.server.auth.providers.azure import EntraOBOToken - import httpx - - @mcp.tool() - async def get_my_emails( - graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]) - ): - async with httpx.AsyncClient() as client: - resp = await client.get( - "https://graph.microsoft.com/v1.0/me/messages", - headers={"Authorization": f"Bearer {graph_token}"} - ) - return resp.json() - ``` - - Note: - For OBO to work, ensure the scopes are included in the AzureProvider's - `additional_authorize_scopes` parameter, and that admin consent has been - granted for those scopes in your Entra app registration. - """ - return cast(str, _EntraOBOToken(scopes)) +import warnings + +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning + +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.azure is deprecated. " + "Import from fastmcp.server.plugins.auth.azure.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) + +from fastmcp.server.plugins.auth.azure.provider import ( + AzureJWTVerifier, + AzureProvider, + EntraOBOToken, +) + +__all__ = ["AzureJWTVerifier", "AzureProvider", "EntraOBOToken"] diff --git a/src/fastmcp/server/auth/providers/clerk.py b/src/fastmcp/server/auth/providers/clerk.py index b17261573..3ab6d5e93 100644 --- a/src/fastmcp/server/auth/providers/clerk.py +++ b/src/fastmcp/server/auth/providers/clerk.py @@ -1,388 +1,23 @@ -"""Clerk OAuth provider for FastMCP. - -This module provides a complete Clerk OAuth integration that's ready to use -with a Clerk domain, client ID, and client secret. It handles all the complexity -of Clerk's OAuth/OIDC flow, token validation, and user management. - -Clerk uses standard OIDC endpoints derived from the instance domain -(e.g., ``https://.clerk.accounts.dev``). Token verification is -performed via the introspection endpoint (RFC 7662) for security-critical -checks (active status, audience, scopes), followed by the userinfo endpoint -for profile enrichment. Userinfo failure is non-fatal. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.clerk import ClerkProvider - - auth = ClerkProvider( - domain="saving-primate-16.clerk.accounts.dev", - client_id="your-clerk-client-id", - client_secret="your-clerk-client-secret", - base_url="https://my-server.com", - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" +"""Backward compatibility shim for Clerk auth provider.""" from __future__ import annotations -import contextlib -from typing import Literal +import warnings -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.clerk is deprecated. " + "Import from fastmcp.server.plugins.auth.clerk.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) -logger = get_logger(__name__) +from fastmcp.server.plugins.auth.clerk.provider import ( + ClerkProvider, + ClerkTokenVerifier, +) - -class ClerkTokenVerifier(TokenVerifier): - """Token verifier for Clerk OAuth tokens. - - Clerk issues standard OIDC tokens. Verification uses the introspection - endpoint (RFC 7662) as the primary security gate — it confirms the token - is active and provides metadata (scopes, expiry, audience). The userinfo - endpoint is called second for profile enrichment (name, email, picture) - and its failure is non-fatal. - - When a ``client_id`` is configured, the audience from introspection is - validated against it. When ``required_scopes`` are configured, - introspection must return the token's scopes — the verifier will not - assume scopes when introspection is unavailable. - """ - - def __init__( - self, - *, - domain: str, - client_id: str | None = None, - client_secret: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - http_client: httpx.AsyncClient | None = None, - ): - """Initialize the Clerk token verifier. - - Args: - domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev") - client_id: Clerk OAuth client ID, used for introspection endpoint authentication - client_secret: Clerk OAuth client secret, used for introspection endpoint authentication - required_scopes: Required OAuth scopes (e.g., ["openid", "email", "profile"]) - timeout_seconds: HTTP request timeout - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused across calls and the caller is responsible for its - lifecycle. When None (default), a fresh client is created per call. - """ - super().__init__(required_scopes=required_scopes) - self.domain = domain.rstrip("/") - self._client_id = client_id - self._client_secret = client_secret - self.timeout_seconds = timeout_seconds - self._http_client = http_client - - self._userinfo_url = f"https://{self.domain}/oauth/userinfo" - self._introspection_url = f"https://{self.domain}/oauth/token_info" - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify a Clerk OAuth token via introspection and userinfo. - - Calls the introspection endpoint first to validate the token and - retrieve auth metadata (active status, scopes, expiry, audience). - If the token passes security checks, the userinfo endpoint is called - for profile enrichment. Userinfo failure is non-fatal. - - When a ``client_id`` is configured, the token's audience must match it. - When ``required_scopes`` are configured, introspection must confirm - them; tokens are rejected if scope information is unavailable. - """ - try: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=self.timeout_seconds) - ) as client: - # Step 1: Validate token via introspection (RFC 7662). - # Security-critical checks (active, audience, scopes) come first. - introspect_data_payload: dict = {"token": token} - introspect_kwargs: dict = { - "data": introspect_data_payload, - "headers": {"User-Agent": "FastMCP-Clerk-OAuth"}, - } - - if self._client_id and self._client_secret: - introspect_kwargs["auth"] = ( - self._client_id, - self._client_secret, - ) - elif self._client_id: - introspect_data_payload["client_id"] = self._client_id - - introspect_response = await client.post( - self._introspection_url, - **introspect_kwargs, - ) - - if introspect_response.status_code != 200: - logger.debug( - "Clerk introspection failed: %d", - introspect_response.status_code, - ) - return None - - introspect_data = introspect_response.json() - - # RFC 7662 requires the 'active' field in the response. - # A missing field indicates a malformed response — reject. - if "active" not in introspect_data or not introspect_data["active"]: - logger.debug( - "Clerk introspection: token inactive or missing 'active' field" - ) - return None - - scope_str = introspect_data.get("scope", "") - token_scopes = scope_str.split() if scope_str else [] - - aud = introspect_data.get("aud") or introspect_data.get("client_id") - - expires_at: int | None = None - exp = introspect_data.get("exp") - if exp is not None: - with contextlib.suppress(ValueError, TypeError): - expires_at = int(exp) - - if self._client_id and aud != self._client_id: - logger.debug( - "Clerk token audience mismatch: got %s, expected %s", - aud, - self._client_id, - ) - return None - - if self.required_scopes: - if not token_scopes: - logger.debug( - "Clerk token missing scope information; " - "cannot verify required scopes %s", - self.required_scopes, - ) - return None - token_scopes_set = set(token_scopes) - required_scopes_set = set(self.required_scopes) - if not required_scopes_set.issubset(token_scopes_set): - logger.debug( - "Clerk token missing required scopes. Has %s, needs %s", - token_scopes_set, - required_scopes_set, - ) - return None - - # Step 2: Fetch user profile via userinfo. - # Enriches the token with profile data (name, email, picture). - sub = introspect_data.get("sub") - user_data: dict = {} - try: - userinfo_response = await client.get( - self._userinfo_url, - headers={ - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-Clerk-OAuth", - }, - ) - if userinfo_response.status_code == 200: - user_data = userinfo_response.json() - if not sub: - sub = user_data.get("sub") - except Exception as e: - logger.debug("Clerk userinfo call failed: %s", e) - - if not sub: - logger.debug("Clerk token missing 'sub' claim") - return None - - access_token = AccessToken( - token=token, - client_id=aud or sub, - scopes=token_scopes, - expires_at=expires_at, - claims={ - "sub": sub, - "aud": aud, - "email": user_data.get("email"), - "email_verified": user_data.get("email_verified"), - "name": user_data.get("name"), - "picture": user_data.get("picture"), - "given_name": user_data.get("given_name"), - "family_name": user_data.get("family_name"), - "preferred_username": user_data.get("preferred_username"), - "iss": user_data.get("iss"), - "clerk_user_data": user_data or None, - }, - ) - logger.debug("Clerk token verified successfully for sub=%s", sub) - return access_token - - except httpx.RequestError as e: - logger.debug("Failed to verify Clerk token: %s", e) - return None - except Exception as e: - logger.debug("Clerk token verification error: %s", e) - return None - - -class ClerkProvider(OAuthProxy): - """Complete Clerk OAuth provider for FastMCP. - - This provider makes it trivial to add Clerk OAuth protection to any - FastMCP server. Provide your Clerk instance domain, OAuth app credentials, - and a base URL, and you're ready to go. - - Clerk uses standard OIDC endpoints derived from the instance domain. - All endpoint URLs are constructed automatically from the domain parameter. - - Features: - - Transparent OAuth proxy to Clerk - - Automatic token validation via Clerk's userinfo & introspection APIs - - User information extraction from Clerk's OIDC claims - - PKCE support (S256) - - Minimal configuration required - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.clerk import ClerkProvider - - auth = ClerkProvider( - domain="saving-primate-16.clerk.accounts.dev", - client_id="your-clerk-client-id", - client_secret="your-clerk-client-secret", - base_url="https://my-server.com", - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - domain: str, - client_id: str, - client_secret: str | None = None, - base_url: AnyHttpUrl | str, - resource_base_url: AnyHttpUrl | str | None = None, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - required_scopes: list[str] | None = None, - valid_scopes: list[str] | None = None, - timeout_seconds: int = 10, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["remember", "external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - extra_authorize_params: dict[str, str] | None = None, - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ): - """Initialize Clerk OAuth provider. - - Args: - domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev"). - This is used to derive all OAuth/OIDC endpoint URLs. - client_id: Clerk OAuth application client ID - client_secret: Clerk OAuth application client secret. - Optional for PKCE public clients. When omitted, jwt_signing_key must be provided. - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - resource_base_url: Optional public base URL for the protected resource metadata - and token audience. Defaults to ``base_url``. - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in Clerk OAuth app (defaults to "/auth/callback") - required_scopes: Required Clerk scopes (defaults to ["openid", "email", "profile"]). - Clerk supports: "openid", "email", "profile", "public_metadata", - "private_metadata", "offline_access". - valid_scopes: All scopes that clients are allowed to request, advertised through - well-known endpoints. Defaults to required_scopes if not provided. - timeout_seconds: HTTP request timeout for Clerk API calls (defaults to 10) - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from ``platformdirs``). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes - are provided, they will be used as is. If a string is provided, it will be derived - into a 32-byte key. If not provided, the upstream client secret will be used to - derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing - clients (default True). When "external", the built-in consent screen is skipped - but no warning is logged, indicating that consent is handled externally by Clerk. - consent_csp_policy: Custom CSP policy for the consent page. - extra_authorize_params: Additional parameters to forward to Clerk's authorization - endpoint. Example: {"prompt": "login"} to force re-authentication. - http_client: Optional httpx.AsyncClient for connection pooling in token verification. - When provided, the client is reused across verify_token calls and the caller - is responsible for its lifecycle. When None (default), a fresh client is created - per call. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - domain = domain.rstrip("/") - - required_scopes_final = ( - parse_scopes(required_scopes) - if required_scopes is not None - else ["openid", "email", "profile"] - ) - - parsed_valid_scopes = ( - parse_scopes(valid_scopes) if valid_scopes is not None else None - ) - - token_verifier = ClerkTokenVerifier( - domain=domain, - client_id=client_id, - client_secret=client_secret, - required_scopes=required_scopes_final, - timeout_seconds=timeout_seconds, - http_client=http_client, - ) - - extra_authorize_params_final = ( - dict(extra_authorize_params) if extra_authorize_params else {} - ) - - super().__init__( - upstream_authorization_endpoint=f"https://{domain}/oauth/authorize", - upstream_token_endpoint=f"https://{domain}/oauth/token", - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - resource_base_url=resource_base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - extra_authorize_params=extra_authorize_params_final or None, - valid_scopes=parsed_valid_scopes, - enable_cimd=enable_cimd, - ) - - logger.debug( - "Initialized Clerk OAuth provider for domain %s with scopes: %s", - domain, - required_scopes_final, - ) +__all__ = ["ClerkProvider", "ClerkTokenVerifier"] diff --git a/src/fastmcp/server/auth/providers/descope.py b/src/fastmcp/server/auth/providers/descope.py index 3bdccf8d5..028152dde 100644 --- a/src/fastmcp/server/auth/providers/descope.py +++ b/src/fastmcp/server/auth/providers/descope.py @@ -1,209 +1,20 @@ -"""Descope authentication provider for FastMCP. - -This module provides DescopeProvider - a complete authentication solution that integrates -with Descope's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR) -for seamless MCP client authentication. -""" +"""Backward compatibility shim for Descope auth provider.""" from __future__ import annotations -from urllib.parse import urlparse +import warnings -import httpx -from pydantic import AnyHttpUrl -from starlette.responses import JSONResponse -from starlette.routing import Route +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.descope is deprecated. " + "Import from fastmcp.server.plugins.auth.descope.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) -logger = get_logger(__name__) +from fastmcp.server.plugins.auth.descope.provider import DescopeProvider - -class DescopeProvider(RemoteAuthProvider): - """Descope metadata provider for DCR (Dynamic Client Registration). - - This provider implements Descope integration using metadata forwarding. - This is the recommended approach for Descope DCR - as it allows Descope to handle the OAuth flow directly while FastMCP acts - as a resource server. - - IMPORTANT SETUP REQUIREMENTS: - - 1. Create an MCP Server in Descope Console: - - Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console - - Create a new MCP Server - - Ensure that **Dynamic Client Registration (DCR)** is enabled - - Note your Well-Known URL - - 2. Note your Well-Known URL: - - Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers) - - Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration`` - - For detailed setup instructions, see: - https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr - - Example: - ```python - from fastmcp.server.auth.providers.descope import DescopeProvider - - # Create Descope metadata provider (JWT verifier created automatically) - descope_auth = DescopeProvider( - config_url="https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration", - base_url="https://your-fastmcp-server.com", - ) - - # Use with FastMCP - mcp = FastMCP("My App", auth=descope_auth) - ``` - """ - - def __init__( - self, - *, - base_url: AnyHttpUrl | str, - config_url: AnyHttpUrl | str | None = None, - project_id: str | None = None, - descope_base_url: AnyHttpUrl | str | None = None, - required_scopes: list[str] | None = None, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - token_verifier: TokenVerifier | None = None, - ): - """Initialize Descope metadata provider. - - Args: - base_url: Public URL of this FastMCP server - config_url: Your Descope Well-Known URL (e.g., "https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration") - This is the new recommended way. If provided, project_id and descope_base_url are ignored. - project_id: Your Descope Project ID (e.g., "P2abc123"). Used with descope_base_url for backwards compatibility. - descope_base_url: Your Descope base URL (e.g., "https://api.descope.com"). Used with project_id for backwards compatibility. - required_scopes: Optional list of scopes that must be present in validated tokens. - These scopes will be included in the protected resource metadata. - scopes_supported: Optional list of scopes to advertise in OAuth metadata. - If None, uses required_scopes. Use this when the scopes clients should - request differ from the scopes enforced on tokens. - resource_name: Optional name for the protected resource metadata. - resource_documentation: Optional documentation URL for the protected resource. - token_verifier: Optional token verifier. If None, creates JWT verifier for Descope - """ - self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) - - # Parse scopes if provided as string - parsed_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else None - ) - - # Determine which API is being used - if config_url is not None: - # New API: use config_url - # Strip /.well-known/openid-configuration from config_url if present - issuer_url = str(config_url) - if issuer_url.endswith("/.well-known/openid-configuration"): - issuer_url = issuer_url[: -len("/.well-known/openid-configuration")] - - # Parse the issuer URL to extract descope_base_url and project_id for other uses - parsed_url = urlparse(issuer_url) - path_parts = parsed_url.path.strip("/").split("/") - - # Extract project_id from path (format: /v1/apps/agentic/P.../M...) - if "agentic" in path_parts: - agentic_index = path_parts.index("agentic") - if agentic_index + 1 < len(path_parts): - self.project_id = path_parts[agentic_index + 1] - else: - raise ValueError( - f"Could not extract project_id from config_url: {issuer_url}" - ) - else: - raise ValueError( - f"Could not find 'agentic' in config_url path: {issuer_url}" - ) - - # Extract descope_base_url (scheme + netloc) - self.descope_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}".rstrip( - "/" - ) - elif project_id is not None and descope_base_url is not None: - # Old API: use project_id and descope_base_url - self.project_id = project_id - descope_base_url_str = str(descope_base_url).rstrip("/") - # Ensure descope_base_url has a scheme - if not descope_base_url_str.startswith(("http://", "https://")): - descope_base_url_str = f"https://{descope_base_url_str}" - self.descope_base_url = descope_base_url_str - # Old issuer format - issuer_url = f"{self.descope_base_url}/v1/apps/{self.project_id}" - else: - raise ValueError( - "Either config_url (new API) or both project_id and descope_base_url (old API) must be provided" - ) - - # Create default JWT verifier if none provided - if token_verifier is None: - token_verifier = JWTVerifier( - jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json", - issuer=issuer_url, - algorithm="RS256", - audience=self.project_id, - required_scopes=parsed_scopes, - ) - - # Initialize RemoteAuthProvider with Descope as the authorization server - super().__init__( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(issuer_url)], - base_url=self.base_url, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get OAuth routes including Descope authorization server metadata forwarding. - - This returns the standard protected resource routes plus an authorization server - metadata endpoint that forwards Descope's OAuth metadata to clients. - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path) - - async def oauth_authorization_server_metadata(request): - """Forward Descope OAuth authorization server metadata with FastMCP customizations.""" - try: - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self.descope_base_url}/v1/apps/{self.project_id}/.well-known/oauth-authorization-server" - ) - response.raise_for_status() - metadata = response.json() - return JSONResponse(metadata) - except Exception as e: - return JSONResponse( - { - "error": "server_error", - "error_description": f"Failed to fetch Descope metadata: {e}", - }, - status_code=500, - ) - - # Add Descope authorization server metadata forwarding - routes.append( - Route( - "/.well-known/oauth-authorization-server", - endpoint=oauth_authorization_server_metadata, - methods=["GET"], - ) - ) - - return routes +__all__ = ["DescopeProvider"] diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py index edf407ae1..8cba6c8e1 100644 --- a/src/fastmcp/server/auth/providers/discord.py +++ b/src/fastmcp/server/auth/providers/discord.py @@ -1,288 +1,23 @@ -"""Discord OAuth provider for FastMCP. - -This module provides a complete Discord OAuth integration that's ready to use -with just a client ID and client secret. It handles all the complexity of -Discord's OAuth flow, token validation, and user management. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.discord import DiscordProvider - - # Simple Discord OAuth protection - auth = DiscordProvider( - client_id="your-discord-client-id", - client_secret="your-discord-client-secret" - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" +"""Backward compatibility shim for Discord auth provider.""" from __future__ import annotations -import contextlib -import time -from datetime import datetime -from typing import Literal +import warnings -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.discord is deprecated. " + "Import from fastmcp.server.plugins.auth.discord.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) -logger = get_logger(__name__) +from fastmcp.server.plugins.auth.discord.provider import ( + DiscordProvider, + DiscordTokenVerifier, +) - -class DiscordTokenVerifier(TokenVerifier): - """Token verifier for Discord OAuth tokens. - - Discord OAuth tokens are opaque (not JWTs), so we verify them - by calling Discord's tokeninfo API to check if they're valid and get user info. - """ - - def __init__( - self, - *, - expected_client_id: str, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - http_client: httpx.AsyncClient | None = None, - ): - """Initialize the Discord token verifier. - - Args: - expected_client_id: Expected Discord OAuth client ID for audience binding - required_scopes: Required OAuth scopes (e.g., ['email']) - timeout_seconds: HTTP request timeout - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused across calls and the caller is responsible for its - lifecycle. When None (default), a fresh client is created per call. - """ - super().__init__(required_scopes=required_scopes) - self.expected_client_id = expected_client_id - self.timeout_seconds = timeout_seconds - self._http_client = http_client - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify Discord OAuth token by calling Discord's tokeninfo API.""" - try: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=self.timeout_seconds) - ) as client: - # Use Discord's tokeninfo endpoint to validate the token - headers = { - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-Discord-OAuth", - } - response = await client.get( - "https://discord.com/api/oauth2/@me", - headers=headers, - ) - - if response.status_code != 200: - logger.debug( - "Discord token verification failed: %d", - response.status_code, - ) - return None - - token_info = response.json() - - # Check if token is expired (Discord returns ISO timestamp) - expires_str = token_info.get("expires") - expires_at = None - if expires_str: - expires_dt = datetime.fromisoformat( - expires_str.replace("Z", "+00:00") - ) - expires_at = int(expires_dt.timestamp()) - if expires_at <= int(time.time()): - logger.debug("Discord token has expired") - return None - - token_scopes = token_info.get("scopes", []) - - # Check required scopes - if self.required_scopes: - token_scopes_set = set(token_scopes) - required_scopes_set = set(self.required_scopes) - if not required_scopes_set.issubset(token_scopes_set): - logger.debug( - "Discord token missing required scopes. Has %d, needs %d", - len(token_scopes_set), - len(required_scopes_set), - ) - return None - - user_data = token_info.get("user", {}) - application = token_info.get("application") or {} - client_id = str(application.get("id", "unknown")) - if client_id != self.expected_client_id: - logger.debug( - "Discord token app ID mismatch: expected %s, got %s", - self.expected_client_id, - client_id, - ) - return None - - # Create AccessToken with Discord user info - access_token = AccessToken( - token=token, - client_id=client_id, - scopes=token_scopes, - expires_at=expires_at, - claims={ - "sub": user_data.get("id"), - "username": user_data.get("username"), - "discriminator": user_data.get("discriminator"), - "avatar": user_data.get("avatar"), - "email": user_data.get("email"), - "verified": user_data.get("verified"), - "locale": user_data.get("locale"), - "discord_user": user_data, - "discord_token_info": token_info, - }, - ) - logger.debug("Discord token verified successfully") - return access_token - - except httpx.RequestError as e: - logger.debug("Failed to verify Discord token: %s", e) - return None - except Exception as e: - logger.debug("Discord token verification error: %s", e) - return None - - -class DiscordProvider(OAuthProxy): - """Complete Discord OAuth provider for FastMCP. - - This provider makes it trivial to add Discord OAuth protection to any - FastMCP server. Just provide your Discord OAuth app credentials and - a base URL, and you're ready to go. - - Features: - - Transparent OAuth proxy to Discord - - Automatic token validation via Discord's API - - User information extraction from Discord APIs - - Minimal configuration required - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.discord import DiscordProvider - - auth = DiscordProvider( - client_id="123456789", - client_secret="discord-client-secret-abc123...", - base_url="https://my-server.com" - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - client_id: str, - client_secret: str, - base_url: AnyHttpUrl | str, - resource_base_url: AnyHttpUrl | str | None = None, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["remember", "external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ): - """Initialize Discord OAuth provider. - - Args: - client_id: Discord OAuth client ID (e.g., "123456789") - client_secret: Discord OAuth client secret (e.g., "S....") - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - resource_base_url: Optional public base URL for the protected resource metadata - and token audience. Defaults to ``base_url``. - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback") - required_scopes: Required Discord scopes (defaults to ["identify"]). Common scopes include: - - "identify" for profile info (default) - - "email" for email access - - "guilds" for server membership info - timeout_seconds: HTTP request timeout for Discord API calls (defaults to 10) - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to Discord. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - http_client: Optional httpx.AsyncClient for connection pooling in token verification. - When provided, the client is reused across verify_token calls and the caller - is responsible for its lifecycle. When None (default), a fresh client is created per call. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - # Parse scopes if provided as string - required_scopes_final = ( - parse_scopes(required_scopes) - if required_scopes is not None - else ["identify"] - ) - - # Create Discord token verifier - token_verifier = DiscordTokenVerifier( - expected_client_id=client_id, - required_scopes=required_scopes_final, - timeout_seconds=timeout_seconds, - http_client=http_client, - ) - - # Initialize OAuth proxy with Discord endpoints - super().__init__( - upstream_authorization_endpoint="https://discord.com/oauth2/authorize", - upstream_token_endpoint="https://discord.com/api/oauth2/token", - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - resource_base_url=resource_base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, # Default to base_url if not specified - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - enable_cimd=enable_cimd, - ) - - logger.debug( - "Initialized Discord OAuth provider for client %s with scopes: %s", - client_id, - required_scopes_final, - ) +__all__ = ["DiscordProvider", "DiscordTokenVerifier"] diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 57ee16799..928534421 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -1,303 +1,23 @@ -"""GitHub OAuth provider for FastMCP. - -This module provides a complete GitHub OAuth integration that's ready to use -with just a client ID and client secret. It handles all the complexity of -GitHub's OAuth flow, token validation, and user management. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.github import GitHubProvider - - # Simple GitHub OAuth protection - auth = GitHubProvider( - client_id="your-github-client-id", - client_secret="your-github-client-secret" - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" +"""Backward compatibility shim for GitHub auth provider.""" from __future__ import annotations -import contextlib -from typing import Literal +import warnings -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.token_cache import TokenCache +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.github is deprecated. " + "Import from fastmcp.server.plugins.auth.github.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) -logger = get_logger(__name__) +from fastmcp.server.plugins.auth.github.provider import ( + GitHubProvider, + GitHubTokenVerifier, +) - -class GitHubTokenVerifier(TokenVerifier): - """Token verifier for GitHub OAuth tokens. - - GitHub OAuth tokens are opaque (not JWTs), so we verify them - by calling GitHub's API to check if they're valid and get user info. - - Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive - integer to cache successful verification results and avoid repeated - GitHub API calls for the same token. - """ - - def __init__( - self, - *, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - cache_ttl_seconds: int | None = None, - max_cache_size: int | None = None, - http_client: httpx.AsyncClient | None = None, - ): - """Initialize the GitHub token verifier. - - Args: - required_scopes: Required OAuth scopes (e.g., ['user:email']) - timeout_seconds: HTTP request timeout - cache_ttl_seconds: How long to cache verification results in seconds. - Caching is disabled by default (None). Set to a positive integer - to enable (e.g., 300 for 5 minutes). - max_cache_size: Maximum number of tokens to cache. Default: 10 000. - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused across calls and the caller is responsible for its - lifecycle. When None (default), a fresh client is created per call. - """ - super().__init__(required_scopes=required_scopes) - self.timeout_seconds = timeout_seconds - self._http_client = http_client - self._cache = TokenCache( - ttl_seconds=cache_ttl_seconds, - max_size=max_cache_size, - ) - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify GitHub OAuth token by calling GitHub API.""" - is_cached, cached_result = self._cache.get(token) - if is_cached: - logger.debug("GitHub token cache hit") - return cached_result - - try: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=self.timeout_seconds) - ) as client: - # Get token info from GitHub API - response = await client.get( - "https://api.github.com/user", - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.v3+json", - "User-Agent": "FastMCP-GitHub-OAuth", - }, - ) - - if response.status_code != 200: - logger.debug( - "GitHub token verification failed: %d - %s", - response.status_code, - response.text[:200], - ) - return None - - user_data = response.json() - - # Get token scopes from GitHub API - # GitHub includes scopes in the X-OAuth-Scopes header - scopes_response = await client.get( - "https://api.github.com/user/repos", # Any authenticated endpoint - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.v3+json", - "User-Agent": "FastMCP-GitHub-OAuth", - }, - ) - - # Extract scopes from X-OAuth-Scopes header if available - scopes_verified = scopes_response.status_code == 200 - oauth_scopes_header = scopes_response.headers.get("x-oauth-scopes", "") - token_scopes = [ - scope.strip() - for scope in oauth_scopes_header.split(",") - if scope.strip() - ] - - # If no scopes in header, assume basic scopes based on successful user API call - if not token_scopes: - token_scopes = ["user"] # Basic scope if we can access user info - - # Check required scopes - if self.required_scopes: - token_scopes_set = set(token_scopes) - required_scopes_set = set(self.required_scopes) - if not required_scopes_set.issubset(token_scopes_set): - logger.debug( - "GitHub token missing required scopes. Has %d, needs %d", - len(token_scopes_set), - len(required_scopes_set), - ) - return None - - # Create AccessToken with GitHub user info - result = AccessToken( - token=token, - client_id=str(user_data.get("id", "unknown")), # Use GitHub user ID - scopes=token_scopes, - expires_at=None, # GitHub tokens don't typically expire - claims={ - "sub": str(user_data["id"]), - "login": user_data.get("login"), - "name": user_data.get("name"), - "email": user_data.get("email"), - "avatar_url": user_data.get("avatar_url"), - "github_user_data": user_data, - }, - ) - if scopes_verified: - self._cache.set(token, result) - return result - - except httpx.RequestError as e: - logger.debug("Failed to verify GitHub token: %s", e) - return None - except Exception as e: - logger.debug("GitHub token verification error: %s", e) - return None - - -class GitHubProvider(OAuthProxy): - """Complete GitHub OAuth provider for FastMCP. - - This provider makes it trivial to add GitHub OAuth protection to any - FastMCP server. Just provide your GitHub OAuth app credentials and - a base URL, and you're ready to go. - - Features: - - Transparent OAuth proxy to GitHub - - Automatic token validation via GitHub API - - User information extraction - - Minimal configuration required - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.github import GitHubProvider - - auth = GitHubProvider( - client_id="Ov23li...", - client_secret="abc123...", - base_url="https://my-server.com" - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - client_id: str, - client_secret: str, - base_url: AnyHttpUrl | str, - resource_base_url: AnyHttpUrl | str | None = None, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - cache_ttl_seconds: int | None = None, - max_cache_size: int | None = None, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["remember", "external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ): - """Initialize GitHub OAuth provider. - - Args: - client_id: GitHub OAuth app client ID (e.g., "Ov23li...") - client_secret: GitHub OAuth app client secret - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - resource_base_url: Optional public base URL for the protected resource metadata - and token audience. Defaults to ``base_url``. - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback") - required_scopes: Required GitHub scopes (defaults to ["user"]) - timeout_seconds: HTTP request timeout for GitHub API calls (defaults to 10) - cache_ttl_seconds: How long to cache token verification results in seconds. - Caching is disabled by default (None). Set to a positive integer to - enable (e.g., 300 for 5 minutes). - max_cache_size: Maximum number of tokens to cache. Default: 10 000. - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to GitHub. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - http_client: Optional httpx.AsyncClient for connection pooling in token verification. - When provided, the client is reused across verify_token calls and the caller - is responsible for its lifecycle. When None (default), a fresh client is created per call. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - # Parse scopes if provided as string - required_scopes_final = ( - parse_scopes(required_scopes) if required_scopes is not None else ["user"] - ) - - # Create GitHub token verifier - token_verifier = GitHubTokenVerifier( - required_scopes=required_scopes_final, - timeout_seconds=timeout_seconds, - cache_ttl_seconds=cache_ttl_seconds, - max_cache_size=max_cache_size, - http_client=http_client, - ) - - # Initialize OAuth proxy with GitHub endpoints - super().__init__( - upstream_authorization_endpoint="https://github.com/login/oauth/authorize", - upstream_token_endpoint="https://github.com/login/oauth/access_token", - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - resource_base_url=resource_base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, # Default to base_url if not specified - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - enable_cimd=enable_cimd, - ) - - logger.debug( - "Initialized GitHub OAuth provider for client %s with scopes: %s", - client_id, - required_scopes_final, - ) +__all__ = ["GitHubProvider", "GitHubTokenVerifier"] diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 55dcad17c..bf54eec47 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -1,365 +1,23 @@ -"""Google OAuth provider for FastMCP. - -This module provides a complete Google OAuth integration that's ready to use -with just a client ID and client secret. It handles all the complexity of -Google's OAuth flow, token validation, and user management. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.google import GoogleProvider - - # Simple Google OAuth protection - auth = GoogleProvider( - client_id="your-google-client-id.apps.googleusercontent.com", - client_secret="your-google-client-secret" - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" +"""Backward compatibility shim for Google auth provider.""" from __future__ import annotations -import contextlib -import time -from typing import Literal +import warnings -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.google is deprecated. " + "Import from fastmcp.server.plugins.auth.google.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) -logger = get_logger(__name__) +from fastmcp.server.plugins.auth.google.provider import ( + GoogleProvider, + GoogleTokenVerifier, +) - -GOOGLE_SCOPE_ALIASES: dict[str, str] = { - "email": "https://www.googleapis.com/auth/userinfo.email", - "profile": "https://www.googleapis.com/auth/userinfo.profile", -} - - -def _normalize_google_scope(scope: str) -> str: - """Normalize a Google scope shorthand to its canonical full URI. - - Google accepts shorthand scopes like "email" and "profile" in authorization - requests, but returns the full URI form in token responses. This normalizes - to the full URI so comparisons work regardless of which form was used. - """ - return GOOGLE_SCOPE_ALIASES.get(scope, scope) - - -class GoogleTokenVerifier(TokenVerifier): - """Token verifier for Google OAuth tokens. - - Google OAuth tokens are opaque (not JWTs), so we verify them by calling - Google's tokeninfo endpoint with the access token as a query parameter. - This returns the OAuth app ID (``aud``), granted scopes, and expiry time. - User profile data (name, picture, etc.) is fetched separately from the - v2 userinfo endpoint when the token is valid. - """ - - def __init__( - self, - *, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - http_client: httpx.AsyncClient | None = None, - ): - """Initialize the Google token verifier. - - Args: - required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email']) - timeout_seconds: HTTP request timeout - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused across calls and the caller is responsible for its - lifecycle. When None (default), a fresh client is created per call. - """ - normalized = ( - [_normalize_google_scope(s) for s in required_scopes] - if required_scopes - else required_scopes - ) - super().__init__(required_scopes=normalized) - self.timeout_seconds = timeout_seconds - self._http_client = http_client - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify a Google OAuth token using the tokeninfo endpoint. - - Calls ``https://oauth2.googleapis.com/tokeninfo?access_token=TOKEN`` - to validate the token and retrieve the OAuth app ID (``aud``), granted - scopes, and expiry time. On success, fetches user profile data from - the v2 userinfo endpoint to populate name, picture, and locale claims. - """ - try: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=self.timeout_seconds) - ) as client: - # Step 1: Verify token via tokeninfo endpoint. - # Returns aud (OAuth app ID), scope (space-separated), expires_in, sub, email. - response = await client.get( - "https://oauth2.googleapis.com/tokeninfo", - params={"access_token": token}, - headers={"User-Agent": "FastMCP-Google-OAuth"}, - ) - - if response.status_code != 200: - logger.debug( - "Google token verification failed: %d", - response.status_code, - ) - return None - - token_data = response.json() - - # aud is the OAuth app ID (client_id / audience) - aud = token_data.get("aud") - if not aud: - logger.debug("Google tokeninfo missing 'aud' claim") - return None - - # sub is required (unique Google user ID) - sub = token_data.get("sub") - if not sub: - logger.debug("Google tokeninfo missing 'sub' claim") - return None - - # Parse scopes directly from the tokeninfo response (space-separated) - scope_str = token_data.get("scope", "") - token_scopes = scope_str.split() if scope_str else [] - - # Check required scopes - if self.required_scopes: - token_scopes_set = set(token_scopes) - required_scopes_set = set(self.required_scopes) - if not required_scopes_set.issubset(token_scopes_set): - logger.debug( - "Google token missing required scopes. Has %d, needs %d", - len(token_scopes_set), - len(required_scopes_set), - ) - return None - - # Compute expiry from expires_in (seconds until expiry) - expires_at: int | None = None - expires_in = token_data.get("expires_in") - if expires_in is not None: - with contextlib.suppress(ValueError, TypeError): - expires_at = int(time.time()) + int(expires_in) - - # Step 2: Fetch user profile from v2 userinfo endpoint. - # tokeninfo provides auth data; userinfo provides name, picture, locale. - user_data: dict = {} - try: - userinfo_response = await client.get( - "https://www.googleapis.com/oauth2/v2/userinfo", - headers={ - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-Google-OAuth", - }, - ) - if userinfo_response.status_code == 200: - user_data = userinfo_response.json() - except Exception as e: - logger.debug("Failed to fetch Google user profile: %s", e) - - access_token = AccessToken( - token=token, - client_id=sub, - scopes=token_scopes, - expires_at=expires_at, - claims={ - "sub": sub, - "aud": aud, - "email": token_data.get("email") or user_data.get("email"), - "email_verified": token_data.get("email_verified") - or user_data.get("verified_email"), - "name": user_data.get("name"), - "picture": user_data.get("picture"), - "given_name": user_data.get("given_name"), - "family_name": user_data.get("family_name"), - "locale": user_data.get("locale"), - "google_user_data": user_data or None, - }, - ) - logger.debug("Google token verified successfully") - return access_token - - except httpx.RequestError as e: - logger.debug("Failed to verify Google token: %s", e) - return None - except Exception as e: - logger.debug("Google token verification error: %s", e) - return None - - -class GoogleProvider(OAuthProxy): - """Complete Google OAuth provider for FastMCP. - - This provider makes it trivial to add Google OAuth protection to any - FastMCP server. Just provide your Google OAuth app credentials and - a base URL, and you're ready to go. - - Features: - - Transparent OAuth proxy to Google - - Automatic token validation via Google's tokeninfo API - - User information extraction from Google APIs - - Minimal configuration required - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.google import GoogleProvider - - auth = GoogleProvider( - client_id="123456789.apps.googleusercontent.com", - client_secret="GOCSPX-abc123...", - base_url="https://my-server.com" - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - client_id: str, - client_secret: str | None = None, - base_url: AnyHttpUrl | str, - resource_base_url: AnyHttpUrl | str | None = None, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - required_scopes: list[str] | None = None, - valid_scopes: list[str] | None = None, - timeout_seconds: int = 10, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["remember", "external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - extra_authorize_params: dict[str, str] | None = None, - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ): - """Initialize Google OAuth provider. - - Args: - client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com") - client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123..."). - Optional for PKCE public clients (e.g., native apps). When omitted, - jwt_signing_key must be provided. - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - resource_base_url: Optional public base URL for the protected resource metadata - and token audience. Defaults to ``base_url``. - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback") - required_scopes: Required Google scopes (defaults to ["openid"]). Common scopes include: - - "openid" for OpenID Connect (default) - - "https://www.googleapis.com/auth/userinfo.email" for email access - - "https://www.googleapis.com/auth/userinfo.profile" for profile info - Google scope shorthands like "email" and "profile" are automatically - normalized to their full URI forms for token verification. - valid_scopes: All scopes that clients are allowed to request, advertised through - well-known endpoints. Defaults to required_scopes if not provided. Use this - when you want clients to be able to request additional scopes beyond the - required minimum. Shorthands are normalized to full URI forms. - timeout_seconds: HTTP request timeout for Google API calls (defaults to 10) - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to Google. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by Google's own consent). - SECURITY WARNING: Only set to False for local development or testing environments. - extra_authorize_params: Additional parameters to forward to Google's authorization endpoint. - By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure - refresh tokens are returned. You can override these defaults or add additional parameters. - Example: {"prompt": "select_account"} to let users choose their Google account. - http_client: Optional httpx.AsyncClient for connection pooling in token verification. - When provided, the client is reused across verify_token calls and the caller - is responsible for its lifecycle. When None (default), a fresh client is created per call. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - # Parse scopes if provided as string - # Google requires at least one scope - openid is the minimal OIDC scope - required_scopes_final = ( - parse_scopes(required_scopes) if required_scopes is not None else ["openid"] - ) - - # Normalize valid_scopes if provided - parsed_valid_scopes = ( - parse_scopes(valid_scopes) if valid_scopes is not None else None - ) - valid_scopes_final = ( - [_normalize_google_scope(s) for s in parsed_valid_scopes] - if parsed_valid_scopes is not None - else None - ) - - # Create Google token verifier - # Normalization of shorthand scopes (e.g. "email" -> full URI) happens - # inside GoogleTokenVerifier so required_scopes match what Google returns. - token_verifier = GoogleTokenVerifier( - required_scopes=required_scopes_final, - timeout_seconds=timeout_seconds, - http_client=http_client, - ) - - # Set Google-specific defaults for extra authorize params - # access_type=offline ensures refresh tokens are returned - # prompt=consent forces consent screen to get refresh token (Google only issues on first auth otherwise) - google_defaults = { - "access_type": "offline", - "prompt": "consent", - } - # User-provided params override defaults - if extra_authorize_params: - google_defaults.update(extra_authorize_params) - extra_authorize_params_final = google_defaults - - # Initialize OAuth proxy with Google endpoints - super().__init__( - upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth", - upstream_token_endpoint="https://oauth2.googleapis.com/token", - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - resource_base_url=resource_base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, # Default to base_url if not specified - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - extra_authorize_params=extra_authorize_params_final, - valid_scopes=valid_scopes_final, - enable_cimd=enable_cimd, - ) - - logger.debug( - "Initialized Google OAuth provider for client %s with scopes: %s", - client_id, - required_scopes_final, - ) +__all__ = ["GoogleProvider", "GoogleTokenVerifier"] diff --git a/src/fastmcp/server/auth/providers/keycloak.py b/src/fastmcp/server/auth/providers/keycloak.py index d018bc4b0..10b5af41f 100644 --- a/src/fastmcp/server/auth/providers/keycloak.py +++ b/src/fastmcp/server/auth/providers/keycloak.py @@ -1,74 +1,22 @@ -"""Keycloak authentication provider for FastMCP.""" +"""Backward compatibility shim for Keycloak auth provider.""" from __future__ import annotations -from pydantic import AnyHttpUrl +import warnings -from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -logger = get_logger(__name__) +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.keycloak is deprecated. " + "Import from fastmcp.server.plugins.auth.keycloak.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) +from fastmcp.server.plugins.auth.keycloak.provider import ( + KeycloakAuthProvider, +) -class KeycloakAuthProvider(RemoteAuthProvider): - """Keycloak authentication provider using Dynamic Client Registration (DCR). - - Requires Keycloak 26.6.0 or later, which includes the fix for DCR compatibility - with MCP clients (https://github.com/keycloak/keycloak/pull/45309). - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider - - auth = KeycloakAuthProvider( - realm_url="https://keycloak.example.com/realms/myrealm", - base_url="https://my-mcp-server.example.com", - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - realm_url: AnyHttpUrl | str, - base_url: AnyHttpUrl | str, - required_scopes: list[str] | str | None = None, - audience: str | list[str] | None = None, - token_verifier: TokenVerifier | None = None, - ): - """Initialize the Keycloak auth provider. - - Args: - realm_url: Keycloak realm URL (e.g., "https://keycloak.example.com/realms/myrealm") - base_url: Public URL of this FastMCP server - required_scopes: Scopes to require on incoming tokens. Defaults to - ["openid"], which ensures the `sub` claim (user identifier) is - present in the access token. Override to require additional scopes. - audience: Optional audience(s) for JWT validation. Recommended for production. - token_verifier: Optional custom token verifier. Defaults to a JWTVerifier - configured for Keycloak's JWKS endpoint and issuer. - """ - self.realm_url = str(realm_url).rstrip("/") - parsed_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else ["openid"] - ) - - if token_verifier is None: - token_verifier = JWTVerifier( - jwks_uri=f"{self.realm_url}/protocol/openid-connect/certs", - issuer=self.realm_url, - algorithm="RS256", - required_scopes=parsed_scopes, - audience=audience, - ) - - super().__init__( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(self.realm_url)], - base_url=AnyHttpUrl(str(base_url).rstrip("/")), - ) +__all__ = ["KeycloakAuthProvider"] diff --git a/src/fastmcp/server/auth/providers/oci.py b/src/fastmcp/server/auth/providers/oci.py index ae765299d..4f633a3bb 100644 --- a/src/fastmcp/server/auth/providers/oci.py +++ b/src/fastmcp/server/auth/providers/oci.py @@ -1,180 +1,20 @@ -"""OCI OIDC provider for FastMCP. +"""Backward compatibility shim for OCI auth provider.""" -The pull request for the provider is submitted to fastmcp. +from __future__ import annotations -This module provides OIDC Implementation to integrate MCP servers with OCI. -You only need OCI Identity Domain's discovery URL, client ID, client secret, and base URL. +import warnings -Post Authentication, you get OCI IAM domain access token. That is not authorized to invoke OCI control plane. -You need to exchange the IAM domain access token for OCI UPST token to invoke OCI control plane APIs. -The sample code below has get_oci_signer function that returns OCI TokenExchangeSigner object. -You can use the signer object to create OCI service object. +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.oci import OCIProvider - from fastmcp.server.dependencies import get_access_token - from fastmcp.utilities.logging import get_logger - - import os - - import oci - from oci.auth.signers import TokenExchangeSigner - - logger = get_logger(__name__) - - # Load configuration from environment - config_url = os.environ.get("OCI_CONFIG_URL") # OCI IAM Domain OIDC discovery URL - client_id = os.environ.get("OCI_CLIENT_ID") # Client ID configured for the OCI IAM Domain Integrated Application - client_secret = os.environ.get("OCI_CLIENT_SECRET") # Client secret configured for the OCI IAM Domain Integrated Application - iam_guid = os.environ.get("OCI_IAM_GUID") # IAM GUID configured for the OCI IAM Domain - - # Simple OCI OIDC protection - auth = OCIProvider( - config_url=config_url, # config URL is the OCI IAM Domain OIDC discovery URL - client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application - client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application - required_scopes=["openid", "profile", "email"], - redirect_path="/auth/callback", - base_url="http://localhost:8000", +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.oci is deprecated. " + "Import from fastmcp.server.plugins.auth.oci.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, ) - # NOTE: For production use, replace this with a thread-safe cache implementation - # such as threading.Lock-protected dict or a proper caching library - _global_token_cache = {} # In memory cache for OCI session token signer +from fastmcp.server.plugins.auth.oci.provider import OCIProvider - def get_oci_signer() -> TokenExchangeSigner: - - authntoken = get_access_token() - tokenID = authntoken.claims.get("jti") - token = authntoken.token - - # Check if the signer exists for the token ID in memory cache - cached_signer = _global_token_cache.get(tokenID) - logger.debug(f"Global cached signer: {cached_signer}") - if cached_signer: - logger.debug(f"Using globally cached signer for token ID: {tokenID}") - return cached_signer - - # If the signer is not yet created for the token then create new OCI signer object - logger.debug(f"Creating new signer for token ID: {tokenID}") - signer = TokenExchangeSigner( - jwt_or_func=token, - oci_domain_id=iam_guid.split(".")[0] if iam_guid else None, # This is same as IAM GUID configured for the OCI IAM Domain - client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application - client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application - ) - logger.debug(f"Signer {signer} created for token ID: {tokenID}") - - #Cache the signer object in memory cache - _global_token_cache[tokenID] = signer - logger.debug(f"Signer cached for token ID: {tokenID}") - - return signer - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" - -from typing import Literal - -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl - -from fastmcp.server.auth.oidc_proxy import OIDCProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class OCIProvider(OIDCProxy): - """An OCI IAM Domain provider implementation for FastMCP. - - This provider is a complete OCI integration that's ready to use with - just the configuration URL, client ID, client secret, and base URL. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.oci import OCIProvider - - import os - - # Load configuration from environment - auth = OCIProvider( - config_url=os.environ.get("OCI_CONFIG_URL"), # OCI IAM Domain OIDC discovery URL - client_id=os.environ.get("OCI_CLIENT_ID"), # Client ID configured for the OCI IAM Domain Integrated Application - client_secret=os.environ.get("OCI_CLIENT_SECRET"), # Client secret configured for the OCI IAM Domain Integrated Application - base_url="http://localhost:8000", - required_scopes=["openid", "profile", "email"], - redirect_path="/auth/callback", - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` - """ - - def __init__( - self, - *, - config_url: AnyHttpUrl | str, - client_id: str, - client_secret: str, - base_url: AnyHttpUrl | str, - resource_base_url: AnyHttpUrl | str | None = None, - audience: str | None = None, - issuer_url: AnyHttpUrl | str | None = None, - required_scopes: list[str] | None = None, - redirect_path: str | None = None, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["remember", "external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - ) -> None: - """Initialize OCI OIDC provider. - - Args: - config_url: OCI OIDC Discovery URL - client_id: OCI IAM Domain Integrated Application client id - client_secret: OCI Integrated Application client secret - base_url: Public URL where OIDC endpoints will be accessible (includes any mount path) - resource_base_url: Optional public base URL for the protected resource metadata - and token audience. Defaults to ``base_url``. - audience: OCI API audience (optional) - issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL. - required_scopes: Required OCI scopes (defaults to ["openid"]) - redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback". - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - """ - # Parse scopes if provided as string - oci_required_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else ["openid"] - ) - - super().__init__( - config_url=config_url, - client_id=client_id, - client_secret=client_secret, - audience=audience, - base_url=base_url, - resource_base_url=resource_base_url, - issuer_url=issuer_url, - redirect_path=redirect_path, - required_scopes=oci_required_scopes, - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - ) - - logger.debug( - "Initialized OCI OAuth provider for client %s with scopes: %s", - client_id, - oci_required_scopes, - ) +__all__ = ["OCIProvider"] diff --git a/src/fastmcp/server/auth/providers/propelauth.py b/src/fastmcp/server/auth/providers/propelauth.py index 82e55e172..26902841e 100644 --- a/src/fastmcp/server/auth/providers/propelauth.py +++ b/src/fastmcp/server/auth/providers/propelauth.py @@ -1,234 +1,23 @@ -"""PropelAuth authentication provider for FastMCP. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.propelauth import PropelAuthProvider - - auth = PropelAuthProvider( - auth_url="https://auth.yourdomain.com", - introspection_client_id="your-client-id", - introspection_client_secret="your-client-secret", - base_url="https://your-fastmcp-server.com", - required_scopes=["read:user_data"], - ) - - mcp = FastMCP("My App", auth=auth) - ``` -""" +"""Backward compatibility shim for PropelAuth auth provider.""" from __future__ import annotations -from typing import TypedDict +import warnings -import httpx -from pydantic import AnyHttpUrl, SecretStr -from starlette.responses import JSONResponse -from starlette.routing import Route +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth import AccessToken, RemoteAuthProvider -from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier -from fastmcp.utilities.logging import get_logger +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.propelauth is deprecated. " + "Import from fastmcp.server.plugins.auth.propelauth.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) -logger = get_logger(__name__) +from fastmcp.server.plugins.auth.propelauth.provider import ( + PropelAuthProvider, + PropelAuthTokenIntrospectionOverrides, +) - -class PropelAuthTokenIntrospectionOverrides(TypedDict, total=False): - timeout_seconds: int - cache_ttl_seconds: int | None - max_cache_size: int | None - http_client: httpx.AsyncClient | None - - -class PropelAuthProvider(RemoteAuthProvider): - """PropelAuth resource server provider using OAuth 2.1 token introspection. - - This provider validates access tokens via PropelAuth's introspection endpoint - and forwards authorization server metadata for OAuth discovery. - - Setup: - 1. Enable MCP authentication in the PropelAuth Dashboard - 2. Configure scopes on the MCP page - 3. Select which redirect URIs to enable by picking which clients you support - 4. Generate introspection credentials (Client ID + Client Secret) - - For detailed setup instructions, see: - https://docs.propelauth.com/mcp-authentication/overview - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.propelauth import PropelAuthProvider - - auth = PropelAuthProvider( - auth_url="https://auth.yourdomain.com", - introspection_client_id="your-client-id", - introspection_client_secret="your-client-secret", - base_url="https://your-fastmcp-server.com", - required_scopes=["read:user_data"], - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - auth_url: AnyHttpUrl | str, - introspection_client_id: str, - introspection_client_secret: str | SecretStr, - base_url: AnyHttpUrl | str, - required_scopes: list[str] | None = None, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - resource: AnyHttpUrl | str | None = None, - token_introspection_overrides: ( - PropelAuthTokenIntrospectionOverrides | None - ) = None, - ): - """Initialize PropelAuth provider. - - Args: - auth_url: Your PropelAuth Auth URL (from the Backend Integration page) - introspection_client_id: Introspection Client ID from the PropelAuth Dashboard - introspection_client_secret: Introspection Client Secret from the PropelAuth Dashboard - base_url: Public URL of this FastMCP server - required_scopes: Optional list of scopes that must be present in tokens - scopes_supported: Optional list of scopes to advertise in OAuth metadata. - If None, uses required_scopes. Use this when the scopes clients should - request differ from the scopes enforced on tokens. - resource_name: Optional name for the protected resource metadata. - resource_documentation: Optional documentation URL for the protected resource. - resource: Optional resource URI (RFC 8707) identifying this MCP server. - Use this when multiple MCP servers share the same PropelAuth - authorization server (e.g. ``resource="https://api.example.com/mcp"``), - so only tokens intended for this MCP server are accepted. - token_introspection_overrides: Optional overrides for the underlying - IntrospectionTokenVerifier (timeout, caching, http_client) - """ - normalized_auth_url = str(auth_url).rstrip("/") - introspection_url = f"{normalized_auth_url}/oauth/2.1/introspect" - authorization_server_url = AnyHttpUrl(f"{normalized_auth_url}/oauth/2.1") - - if resource is None: - self._resource = None - logger.debug( - "PropelAuthProvider: no resource configured, audience checking disabled" - ) - else: - self._resource = str(resource) - - token_verifier = self._create_token_verifier( - introspection_url=introspection_url, - client_id=introspection_client_id, - client_secret=introspection_client_secret, - required_scopes=required_scopes, - introspection_overrides=token_introspection_overrides, - ) - - self._normalized_auth_url = normalized_auth_url - super().__init__( - token_verifier=token_verifier, - authorization_servers=[authorization_server_url], - base_url=base_url, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get routes for this provider. - - Includes the standard routes from the RemoteAuthProvider (protected resource metadata routes (RFC 9728)), - and creates an authorization server metadata route that forwards to PropelAuth's route - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - routes = super().get_routes(mcp_path) - - async def oauth_authorization_server_metadata(request): - """Forward PropelAuth OAuth authorization server metadata""" - try: - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self._normalized_auth_url}/.well-known/oauth-authorization-server/oauth/2.1" - ) - response.raise_for_status() - metadata = response.json() - return JSONResponse(metadata) - except Exception as e: - return JSONResponse( - { - "error": "server_error", - "error_description": f"Failed to fetch PropelAuth metadata: {e}", - }, - status_code=500, - ) - - routes.append( - Route( - "/.well-known/oauth-authorization-server", - endpoint=oauth_authorization_server_metadata, - methods=["GET"], - ) - ) - - return routes - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token and check the ``aud`` claim against the configured resource.""" - result = await super().verify_token(token) - if result is None or self._resource is None: - return result - - aud = result.claims.get("aud") - if aud != self._resource: - logger.debug( - "PropelAuthProvider: token audience %r does not match resource %s", - aud, - self._resource, - ) - return None - - return result - - def _create_token_verifier( - self, - introspection_url: str, - client_id: str, - client_secret: str | SecretStr, - required_scopes: list[str] | None, - introspection_overrides: PropelAuthTokenIntrospectionOverrides | None, - ) -> IntrospectionTokenVerifier: - # Being defensive here, check for only the fields we are expecting - safe_overrides: PropelAuthTokenIntrospectionOverrides = {} - if introspection_overrides is not None: - if "timeout_seconds" in introspection_overrides: - safe_overrides["timeout_seconds"] = introspection_overrides[ - "timeout_seconds" - ] - if "cache_ttl_seconds" in introspection_overrides: - safe_overrides["cache_ttl_seconds"] = introspection_overrides[ - "cache_ttl_seconds" - ] - if "max_cache_size" in introspection_overrides: - safe_overrides["max_cache_size"] = introspection_overrides[ - "max_cache_size" - ] - if "http_client" in introspection_overrides: - safe_overrides["http_client"] = introspection_overrides["http_client"] - - return IntrospectionTokenVerifier( - introspection_url=introspection_url, - client_id=client_id, - client_secret=client_secret, - required_scopes=required_scopes, - **safe_overrides, - ) +__all__ = ["PropelAuthProvider", "PropelAuthTokenIntrospectionOverrides"] diff --git a/src/fastmcp/server/auth/providers/scalekit.py b/src/fastmcp/server/auth/providers/scalekit.py index ffcacc9c5..fd4b5a88f 100644 --- a/src/fastmcp/server/auth/providers/scalekit.py +++ b/src/fastmcp/server/auth/providers/scalekit.py @@ -1,212 +1,20 @@ -"""Scalekit authentication provider for FastMCP. - -This module provides ScalekitProvider - a complete authentication solution that integrates -with Scalekit's OAuth 2.1 and OpenID Connect services, supporting Resource Server -authentication for seamless MCP client authentication. -""" +"""Backward compatibility shim for Scalekit auth provider.""" from __future__ import annotations -import httpx -from pydantic import AnyHttpUrl -from starlette.responses import JSONResponse -from starlette.routing import Route +import warnings -from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -logger = get_logger(__name__) +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.scalekit is deprecated. " + "Import from fastmcp.server.plugins.auth.scalekit.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) +from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider -class ScalekitProvider(RemoteAuthProvider): - """Scalekit resource server provider for OAuth 2.1 authentication. - - This provider implements Scalekit integration using resource server pattern. - FastMCP acts as a protected resource server that validates access tokens issued - by Scalekit's authorization server. - - IMPORTANT SETUP REQUIREMENTS: - - 1. Create an MCP Server in Scalekit Dashboard: - - Go to your [Scalekit Dashboard](https://app.scalekit.com/) - - Navigate to MCP Servers section - - Register a new MCP Server with appropriate scopes - - Ensure the Resource Identifier matches exactly what you configure as MCP URL - - Note the Resource ID - - 2. Environment Configuration: - - Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com) - - Set SCALEKIT_RESOURCE_ID from your created resource - - Set BASE_URL to your FastMCP server's public URL - - For detailed setup instructions, see: - https://docs.scalekit.com/mcp/overview/ - - Example: - ```python - from fastmcp.server.auth.providers.scalekit import ScalekitProvider - - # Create Scalekit resource server provider - scalekit_auth = ScalekitProvider( - environment_url="https://your-env.scalekit.com", - resource_id="sk_resource_...", - base_url="https://your-fastmcp-server.com", - ) - - # Use with FastMCP - mcp = FastMCP("My App", auth=scalekit_auth) - ``` - """ - - def __init__( - self, - *, - environment_url: AnyHttpUrl | str, - resource_id: str, - base_url: AnyHttpUrl | str | None = None, - mcp_url: AnyHttpUrl | str | None = None, - client_id: str | None = None, - required_scopes: list[str] | None = None, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - token_verifier: TokenVerifier | None = None, - ): - """Initialize Scalekit resource server provider. - - Args: - environment_url: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com") - resource_id: Your Scalekit resource ID - base_url: Public URL of this FastMCP server (or use mcp_url for backwards compatibility) - mcp_url: Deprecated alias for base_url. Will be removed in a future release. - client_id: Deprecated parameter, no longer required. Will be removed in a future release. - required_scopes: Optional list of scopes that must be present in tokens - scopes_supported: Optional list of scopes to advertise in OAuth metadata. - If None, uses required_scopes. Use this when the scopes clients should - request differ from the scopes enforced on tokens. - resource_name: Optional name for the protected resource metadata. - resource_documentation: Optional documentation URL for the protected resource. - token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit - """ - # Resolve base_url from mcp_url if needed (backwards compatibility) - resolved_base_url = base_url or mcp_url - if not resolved_base_url: - raise ValueError("Either base_url or mcp_url must be provided") - - if mcp_url is not None: - logger.warning( - "ScalekitProvider parameter 'mcp_url' is deprecated and will be removed in a future release. " - "Rename it to 'base_url'." - ) - - if client_id is not None: - logger.warning( - "ScalekitProvider no longer requires 'client_id'. The parameter is accepted only for backward " - "compatibility and will be removed in a future release." - ) - - self.environment_url = str(environment_url).rstrip("/") - self.resource_id = resource_id - parsed_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else [] - ) - self.required_scopes = parsed_scopes - base_url_value = str(resolved_base_url) - - logger.debug( - "Initializing ScalekitProvider: environment_url=%s resource_id=%s base_url=%s required_scopes=%s", - self.environment_url, - self.resource_id, - base_url_value, - self.required_scopes, - ) - - # Create default JWT verifier if none provided - if token_verifier is None: - logger.debug( - "Creating default JWTVerifier for Scalekit: jwks_uri=%s issuer=%s required_scopes=%s", - f"{self.environment_url}/keys", - self.environment_url, - self.required_scopes, - ) - token_verifier = JWTVerifier( - jwks_uri=f"{self.environment_url}/keys", - issuer=self.environment_url, - algorithm="RS256", - audience=self.resource_id, - required_scopes=self.required_scopes or None, - ) - else: - logger.debug("Using custom token verifier for ScalekitProvider") - - # Initialize RemoteAuthProvider with Scalekit as the authorization server - super().__init__( - token_verifier=token_verifier, - authorization_servers=[ - AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}") - ], - base_url=base_url_value, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get OAuth routes including Scalekit authorization server metadata forwarding. - - This returns the standard protected resource routes plus an authorization server - metadata endpoint that forwards Scalekit's OAuth metadata to clients. - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path) - logger.debug( - "Preparing Scalekit metadata routes: mcp_path=%s resource_id=%s", - mcp_path, - self.resource_id, - ) - - async def oauth_authorization_server_metadata(request): - """Forward Scalekit OAuth authorization server metadata with FastMCP customizations.""" - try: - metadata_url = f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}" - logger.debug( - "Fetching Scalekit OAuth metadata: metadata_url=%s", metadata_url - ) - async with httpx.AsyncClient() as client: - response = await client.get(metadata_url) - response.raise_for_status() - metadata = response.json() - logger.debug( - "Scalekit metadata fetched successfully: metadata_keys=%s", - list(metadata.keys()), - ) - return JSONResponse(metadata) - except Exception as e: - logger.error(f"Failed to fetch Scalekit metadata: {e}") - return JSONResponse( - { - "error": "server_error", - "error_description": f"Failed to fetch Scalekit metadata: {e}", - }, - status_code=500, - ) - - # Add Scalekit authorization server metadata forwarding - routes.append( - Route( - "/.well-known/oauth-authorization-server", - endpoint=oauth_authorization_server_metadata, - methods=["GET"], - ) - ) - - return routes +__all__ = ["ScalekitProvider"] diff --git a/src/fastmcp/server/auth/providers/supabase.py b/src/fastmcp/server/auth/providers/supabase.py index 527d701ee..08d5a3cd7 100644 --- a/src/fastmcp/server/auth/providers/supabase.py +++ b/src/fastmcp/server/auth/providers/supabase.py @@ -1,181 +1,20 @@ -"""Supabase authentication provider for FastMCP. - -This module provides SupabaseProvider - a complete authentication solution that integrates -with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR) -for seamless MCP client authentication. -""" +"""Backward compatibility shim for Supabase auth provider.""" from __future__ import annotations -from typing import Literal +import warnings -import httpx -from pydantic import AnyHttpUrl -from starlette.responses import JSONResponse -from starlette.routing import Route +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.supabase is deprecated. " + "Import from fastmcp.server.plugins.auth.supabase.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) -logger = get_logger(__name__) +from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider - -class SupabaseProvider(RemoteAuthProvider): - """Supabase metadata provider for DCR (Dynamic Client Registration). - - This provider implements Supabase Auth integration using metadata forwarding. - This approach allows Supabase to handle the OAuth flow directly while FastMCP acts - as a resource server, verifying JWTs issued by Supabase Auth. - - IMPORTANT SETUP REQUIREMENTS: - - 1. Supabase Project Setup: - - Create a Supabase project at https://supabase.com - - Note your project URL (e.g., "https://abc123.supabase.co") - - Configure your JWT algorithm in Supabase Auth settings (RS256 or ES256) - - Asymmetric keys (RS256/ES256) are recommended for production - - 2. JWT Verification: - - FastMCP verifies JWTs using the JWKS endpoint at {project_url}{auth_route}/.well-known/jwks.json - - JWTs are issued by {project_url}{auth_route} - - Default auth_route is "/auth/v1" (can be customized for self-hosted setups) - - Tokens are cached for up to 10 minutes by Supabase's edge servers - - Algorithm must match your Supabase Auth configuration - - 3. Authorization: - - Supabase uses Row Level Security (RLS) policies for database authorization - - OAuth-level scopes are an upcoming feature in Supabase Auth - - Both approaches will be supported once scope handling is available - - For detailed setup instructions, see: - https://supabase.com/docs/guides/auth/jwts - - Example: - ```python - from fastmcp.server.auth.providers.supabase import SupabaseProvider - - # Create Supabase metadata provider (JWT verifier created automatically) - supabase_auth = SupabaseProvider( - project_url="https://abc123.supabase.co", - base_url="https://your-fastmcp-server.com", - algorithm="ES256", # Match your Supabase Auth configuration - ) - - # Use with FastMCP - mcp = FastMCP("My App", auth=supabase_auth) - ``` - """ - - def __init__( - self, - *, - project_url: AnyHttpUrl | str, - base_url: AnyHttpUrl | str, - auth_route: str = "/auth/v1", - algorithm: Literal["RS256", "ES256"] = "ES256", - required_scopes: list[str] | None = None, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - token_verifier: TokenVerifier | None = None, - ): - """Initialize Supabase metadata provider. - - Args: - project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co") - base_url: Public URL of this FastMCP server - auth_route: Supabase Auth route. Defaults to "/auth/v1". Can be customized - for self-hosted Supabase Auth setups using custom routes. - algorithm: JWT signing algorithm (RS256 or ES256). Must match your - Supabase Auth configuration. Defaults to ES256. - required_scopes: Optional list of scopes to require for all requests. - Note: Supabase currently uses RLS policies for authorization. OAuth-level - scopes are an upcoming feature. - scopes_supported: Optional list of scopes to advertise in OAuth metadata. - If None, uses required_scopes. Use this when the scopes clients should - request differ from the scopes enforced on tokens. - resource_name: Optional name for the protected resource metadata. - resource_documentation: Optional documentation URL for the protected resource. - token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase - """ - self.project_url = str(project_url).rstrip("/") - self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) - self.auth_route = auth_route.strip("/") - - # Parse scopes if provided as string - parsed_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else None - ) - - # Create default JWT verifier if none provided - if token_verifier is None: - logger.warning( - "SupabaseProvider cannot validate token audience for the specific resource " - "because Supabase Auth does not support RFC 8707 resource indicators. " - "This may leave the server vulnerable to cross-server token replay." - ) - token_verifier = JWTVerifier( - jwks_uri=f"{self.project_url}/{self.auth_route}/.well-known/jwks.json", - issuer=f"{self.project_url}/{self.auth_route}", - algorithm=algorithm, - audience="authenticated", - required_scopes=parsed_scopes, - ) - - # Initialize RemoteAuthProvider with Supabase as the authorization server - super().__init__( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(f"{self.project_url}/{self.auth_route}")], - base_url=self.base_url, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get OAuth routes including Supabase authorization server metadata forwarding. - - This returns the standard protected resource routes plus an authorization server - metadata endpoint that forwards Supabase's OAuth metadata to clients. - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path) - - async def oauth_authorization_server_metadata(request): - """Forward Supabase OAuth authorization server metadata with FastMCP customizations.""" - try: - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self.project_url}/{self.auth_route}/.well-known/oauth-authorization-server" - ) - response.raise_for_status() - metadata = response.json() - return JSONResponse(metadata) - except Exception as e: - return JSONResponse( - { - "error": "server_error", - "error_description": f"Failed to fetch Supabase metadata: {e}", - }, - status_code=500, - ) - - # Add Supabase authorization server metadata forwarding - routes.append( - Route( - "/.well-known/oauth-authorization-server", - endpoint=oauth_authorization_server_metadata, - methods=["GET"], - ) - ) - - return routes +__all__ = ["SupabaseProvider"] diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 91ab83dc1..0c9dcb855 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -1,428 +1,25 @@ -"""WorkOS authentication providers for FastMCP. - -This module provides two WorkOS authentication strategies: - -1. WorkOSProvider - OAuth proxy for WorkOS Connect applications (non-DCR) -2. AuthKitProvider - DCR-compliant provider for WorkOS AuthKit - -Choose based on your WorkOS setup and authentication requirements. -""" +"""Backward compatibility shim for WorkOS auth providers.""" from __future__ import annotations -import contextlib -from typing import Literal +import warnings -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl -from starlette.responses import JSONResponse -from starlette.routing import Route +from fastmcp import settings +from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger +if settings.deprecation_warnings: + warnings.warn( + "fastmcp.server.auth.providers.workos is deprecated. " + "Import from fastmcp.server.plugins.auth.workos.provider or " + "fastmcp.server.plugins.auth.authkit.provider instead.", + FastMCPDeprecationWarning, + stacklevel=2, + ) -logger = get_logger(__name__) +from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider +from fastmcp.server.plugins.auth.workos.provider import ( + WorkOSProvider, + WorkOSTokenVerifier, +) - -class WorkOSTokenVerifier(TokenVerifier): - """Token verifier for WorkOS OAuth tokens. - - WorkOS AuthKit tokens are opaque, so we verify them by calling - the /oauth2/userinfo endpoint to check validity and get user info. - """ - - def __init__( - self, - *, - authkit_domain: str, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - http_client: httpx.AsyncClient | None = None, - ): - """Initialize the WorkOS token verifier. - - Args: - authkit_domain: WorkOS AuthKit domain (e.g., "https://your-app.authkit.app") - required_scopes: Required OAuth scopes - timeout_seconds: HTTP request timeout - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused across calls and the caller is responsible for its - lifecycle. When None (default), a fresh client is created per call. - """ - super().__init__(required_scopes=required_scopes) - self.authkit_domain = authkit_domain.rstrip("/") - self.timeout_seconds = timeout_seconds - self._http_client = http_client - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify WorkOS OAuth token by calling userinfo endpoint.""" - try: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=self.timeout_seconds) - ) as client: - # Use WorkOS AuthKit userinfo endpoint to validate token - response = await client.get( - f"{self.authkit_domain}/oauth2/userinfo", - headers={ - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-WorkOS-OAuth", - }, - ) - - if response.status_code != 200: - logger.debug( - "WorkOS token verification failed: %d - %s", - response.status_code, - response.text[:200], - ) - return None - - user_data = response.json() - token_scopes = ( - parse_scopes(user_data.get("scope") or user_data.get("scopes")) - or [] - ) - - if self.required_scopes and not all( - scope in token_scopes for scope in self.required_scopes - ): - logger.debug( - "WorkOS token missing required scopes. required=%s actual=%s", - self.required_scopes, - token_scopes, - ) - return None - - # Create AccessToken with WorkOS user info - return AccessToken( - token=token, - client_id=str(user_data.get("sub", "unknown")), - scopes=token_scopes, - expires_at=None, # Will be set from token introspection if needed - claims={ - "sub": user_data.get("sub"), - "email": user_data.get("email"), - "email_verified": user_data.get("email_verified"), - "name": user_data.get("name"), - "given_name": user_data.get("given_name"), - "family_name": user_data.get("family_name"), - }, - ) - - except httpx.RequestError as e: - logger.debug("Failed to verify WorkOS token: %s", e) - return None - except Exception as e: - logger.debug("WorkOS token verification error: %s", e) - return None - - -class WorkOSProvider(OAuthProxy): - """Complete WorkOS OAuth provider for FastMCP. - - This provider implements WorkOS AuthKit OAuth using the OAuth Proxy pattern. - It provides OAuth2 authentication for users through WorkOS Connect applications. - - Features: - - Transparent OAuth proxy to WorkOS AuthKit - - Automatic token validation via userinfo endpoint - - User information extraction from ID tokens - - Support for standard OAuth scopes (openid, profile, email) - - Setup Requirements: - 1. Create a WorkOS Connect application in your dashboard - 2. Note your AuthKit domain (e.g., "https://your-app.authkit.app") - 3. Configure redirect URI as: http://localhost:8000/auth/callback - 4. Note your Client ID and Client Secret - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.workos import WorkOSProvider - - auth = WorkOSProvider( - client_id="client_123", - client_secret="sk_test_456", - authkit_domain="https://your-app.authkit.app", - base_url="http://localhost:8000" - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - client_id: str, - client_secret: str, - authkit_domain: str, - base_url: AnyHttpUrl | str, - resource_base_url: AnyHttpUrl | str | None = None, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["remember", "external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ): - """Initialize WorkOS OAuth provider. - - Args: - client_id: WorkOS client ID - client_secret: WorkOS client secret - authkit_domain: Your WorkOS AuthKit domain (e.g., "https://your-app.authkit.app") - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - resource_base_url: Optional public base URL for the protected resource metadata - and token audience. Defaults to ``base_url``. - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback") - required_scopes: Required OAuth scopes (no default) - timeout_seconds: HTTP request timeout for WorkOS API calls (defaults to 10) - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to WorkOS. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - http_client: Optional httpx.AsyncClient for connection pooling in token verification. - When provided, the client is reused across verify_token calls and the caller - is responsible for its lifecycle. When None (default), a fresh client is created per call. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - # Apply defaults and ensure authkit_domain is a full URL - authkit_domain_str = authkit_domain - if not authkit_domain_str.startswith(("http://", "https://")): - authkit_domain_str = f"https://{authkit_domain_str}" - authkit_domain_final = authkit_domain_str.rstrip("/") - scopes_final = ( - parse_scopes(required_scopes) if required_scopes is not None else [] - ) - - # Create WorkOS token verifier - token_verifier = WorkOSTokenVerifier( - authkit_domain=authkit_domain_final, - required_scopes=scopes_final, - timeout_seconds=timeout_seconds, - http_client=http_client, - ) - - # Initialize OAuth proxy with WorkOS AuthKit endpoints - super().__init__( - upstream_authorization_endpoint=f"{authkit_domain_final}/oauth2/authorize", - upstream_token_endpoint=f"{authkit_domain_final}/oauth2/token", - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - resource_base_url=resource_base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, # Default to base_url if not specified - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - enable_cimd=enable_cimd, - ) - - logger.debug( - "Initialized WorkOS OAuth provider for client %s with AuthKit domain %s", - client_id, - authkit_domain_final, - ) - - -class AuthKitProvider(RemoteAuthProvider): - """AuthKit metadata provider for DCR (Dynamic Client Registration). - - This provider implements AuthKit integration using metadata forwarding - instead of OAuth proxying. This is the recommended approach for WorkOS DCR - as it allows WorkOS to handle the OAuth flow directly while FastMCP acts - as a resource server. - - IMPORTANT SETUP REQUIREMENTS: - - 1. Enable Dynamic Client Registration in WorkOS Dashboard: - - Go to Applications → Configuration - - Toggle "Dynamic Client Registration" to enabled - - 2. Configure your FastMCP server URL as a callback: - - Add your server URL to the Redirects tab in WorkOS dashboard - - Example: https://your-fastmcp-server.com/oauth2/callback - - For detailed setup instructions, see: - https://workos.com/docs/authkit/mcp/integrating/token-verification - - Token audience is bound to this server automatically: when the MCP - mount path becomes known (typically at ``http_app()`` construction), - ``JWTVerifier.audience`` is set to the resource URL advertised in - ``.well-known/oauth-protected-resource``. Enable Resource Indicators - (RFC 8707) in your WorkOS Dashboard and list that same URL — AuthKit - will then mint tokens with the matching ``aud`` claim. - - Example: - ```python - from fastmcp.server.auth.providers.workos import AuthKitProvider - - workos_auth = AuthKitProvider( - authkit_domain="https://your-workos-domain.authkit.app", - base_url="https://your-fastmcp-server.com", - ) - - mcp = FastMCP("My App", auth=workos_auth) - ``` - """ - - def __init__( - self, - *, - authkit_domain: AnyHttpUrl | str, - base_url: AnyHttpUrl | str, - resource_base_url: AnyHttpUrl | str | None = None, - required_scopes: list[str] | None = None, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - token_verifier: TokenVerifier | None = None, - ): - """Initialize AuthKit metadata provider. - - Args: - authkit_domain: Your AuthKit domain (e.g., "https://your-app.authkit.app") - base_url: Public URL of this FastMCP server - resource_base_url: Optional public base URL for the protected resource. - When provided, this URL is advertised in protected resource metadata - instead of ``base_url``. Useful when OAuth callbacks and the protected - MCP resource live under different public URLs. - required_scopes: Optional list of scopes to require for all requests - scopes_supported: Optional list of scopes to advertise in OAuth metadata. - If None, uses required_scopes. Use this when the scopes clients should - request differ from the scopes enforced on tokens. - resource_name: Optional name for the protected resource metadata. - resource_documentation: Optional documentation URL for the protected resource. - token_verifier: Optional token verifier. If provided, it is used as-is and - audience auto-wiring is skipped — the caller is responsible for setting - an appropriate ``audience``. If None (default), a ``JWTVerifier`` is - created with audience bound to this server's resource URL. - """ - self.authkit_domain = str(authkit_domain).rstrip("/") - self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) - - # Parse scopes if provided as string - parsed_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else None - ) - - # When no custom verifier is provided, we own the JWTVerifier and can - # bind its audience to our resource URL once set_mcp_path() is called. - self._auto_bind_audience = token_verifier is None - if token_verifier is None: - token_verifier = JWTVerifier( - jwks_uri=f"{self.authkit_domain}/oauth2/jwks", - issuer=self.authkit_domain, - algorithm="RS256", - required_scopes=parsed_scopes, - ) - - # Initialize RemoteAuthProvider with AuthKit as the authorization server - super().__init__( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(self.authkit_domain)], - base_url=self.base_url, - resource_base_url=resource_base_url, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - def set_mcp_path(self, mcp_path: str | None) -> None: - """Bind the default verifier's audience to this server's resource URL. - - AuthKit with Resource Indicators (RFC 8707) mints tokens whose ``aud`` - claim equals the resource URL the client requested — which is the URL - we advertise in ``.well-known/oauth-protected-resource``. Binding the - audience here keeps validation in lock-step with what clients are sent. - """ - super().set_mcp_path(mcp_path) - if ( - self._auto_bind_audience - and self._resource_url is not None - and isinstance(self.token_verifier, JWTVerifier) - ): - resource_url = str(self._resource_url) - self.token_verifier.audience = resource_url - logger.info( - "AuthKit tokens will be validated against aud=%s. " - "Configure this URL as a Resource Indicator in the WorkOS Dashboard.", - resource_url, - ) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get OAuth routes including AuthKit authorization server metadata forwarding. - - This returns the standard protected resource routes plus an authorization server - metadata endpoint that forwards AuthKit's OAuth metadata to clients. - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path) - - async def oauth_authorization_server_metadata(request): - """Forward AuthKit OAuth authorization server metadata with FastMCP customizations.""" - try: - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self.authkit_domain}/.well-known/oauth-authorization-server" - ) - response.raise_for_status() - metadata = response.json() - return JSONResponse(metadata) - except Exception as e: - return JSONResponse( - { - "error": "server_error", - "error_description": f"Failed to fetch AuthKit metadata: {e}", - }, - status_code=500, - ) - - # Add AuthKit authorization server metadata forwarding - routes.append( - Route( - "/.well-known/oauth-authorization-server", - endpoint=oauth_authorization_server_metadata, - methods=["GET"], - ) - ) - - return routes +__all__ = ["AuthKitProvider", "WorkOSProvider", "WorkOSTokenVerifier"] diff --git a/src/fastmcp/server/plugins/auth/__init__.py b/src/fastmcp/server/plugins/auth/__init__.py index f78bb69f2..3fa68acda 100644 --- a/src/fastmcp/server/plugins/auth/__init__.py +++ b/src/fastmcp/server/plugins/auth/__init__.py @@ -1,67 +1,3 @@ -"""Auth plugins for FastMCP.""" +"""Auth plugin namespace for FastMCP.""" -from fastmcp.server.plugins.auth.providers import ( - Auth0Auth, - Auth0AuthConfig, - AuthKitAuth, - AuthKitAuthConfig, - AWSCognitoAuth, - AWSCognitoAuthConfig, - AzureAuth, - AzureAuthConfig, - ClerkAuth, - ClerkAuthConfig, - DescopeAuth, - DescopeAuthConfig, - DiscordAuth, - DiscordAuthConfig, - GitHubAuth, - GitHubAuthConfig, - GoogleAuth, - GoogleAuthConfig, - KeycloakAuth, - KeycloakAuthConfig, - OCIAuth, - OCIAuthConfig, - PropelAuth, - PropelAuthConfig, - ScalekitAuth, - ScalekitAuthConfig, - SupabaseAuth, - SupabaseAuthConfig, - WorkOSAuth, - WorkOSAuthConfig, -) - -__all__ = [ - "AWSCognitoAuth", - "AWSCognitoAuthConfig", - "Auth0Auth", - "Auth0AuthConfig", - "AuthKitAuth", - "AuthKitAuthConfig", - "AzureAuth", - "AzureAuthConfig", - "ClerkAuth", - "ClerkAuthConfig", - "DescopeAuth", - "DescopeAuthConfig", - "DiscordAuth", - "DiscordAuthConfig", - "GitHubAuth", - "GitHubAuthConfig", - "GoogleAuth", - "GoogleAuthConfig", - "KeycloakAuth", - "KeycloakAuthConfig", - "OCIAuth", - "OCIAuthConfig", - "PropelAuth", - "PropelAuthConfig", - "ScalekitAuth", - "ScalekitAuthConfig", - "SupabaseAuth", - "SupabaseAuthConfig", - "WorkOSAuth", - "WorkOSAuthConfig", -] +__all__: list[str] = [] diff --git a/src/fastmcp/server/plugins/auth/_base.py b/src/fastmcp/server/plugins/auth/_base.py new file mode 100644 index 000000000..f5a7f93f5 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/_base.py @@ -0,0 +1,65 @@ +"""Shared primitives for first-party auth plugins.""" + +from __future__ import annotations + +from typing import Any, Generic, Literal, TypeVar + +from pydantic import AnyHttpUrl, BaseModel, ConfigDict + +from fastmcp.server.plugins.base import Plugin + +ConsentMode = bool | Literal["remember", "external"] +Algorithm = Literal["RS256", "ES256"] +ConfigT = TypeVar("ConfigT", bound=BaseModel) + + +class AuthPlugin(Plugin[ConfigT], Generic[ConfigT]): + def _require(self, *fields: str) -> None: + missing = [field for field in fields if getattr(self.config, field) is None] + if missing: + names = ", ".join(f"`{field}`" for field in missing) + raise ValueError(f"{type(self).__name__} requires {names}.") + + def _require_one(self, *fields: str) -> None: + if not any(getattr(self.config, field) is not None for field in fields): + names = " or ".join(f"`{field}`" for field in fields) + raise ValueError(f"{type(self).__name__} requires {names}.") + + def _kwargs(self, *fields: str) -> dict[str, Any]: + return { + field: getattr(self.config, field) + for field in fields + if getattr(self.config, field) is not None + } + + +class PluginConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class OAuthProxyConfig(PluginConfig): + base_url: AnyHttpUrl | str | None = None + resource_base_url: AnyHttpUrl | str | None = None + issuer_url: AnyHttpUrl | str | None = None + redirect_path: str | None = None + required_scopes: list[str] | None = None + allowed_client_redirect_uris: list[str] | None = None + jwt_signing_key: str | None = None + require_authorization_consent: ConsentMode = True + consent_csp_policy: str | None = None + forward_resource: bool = True + + +class OAuthProviderConfig(OAuthProxyConfig): + client_id: str | None = None + client_secret: str | None = None + timeout_seconds: int = 10 + enable_cimd: bool = True + + +class RemoteAuthConfig(PluginConfig): + base_url: AnyHttpUrl | str | None = None + required_scopes: list[str] | None = None + scopes_supported: list[str] | None = None + resource_name: str | None = None + resource_documentation: AnyHttpUrl | None = None diff --git a/src/fastmcp/server/plugins/auth/auth0/__init__.py b/src/fastmcp/server/plugins/auth/auth0/__init__.py new file mode 100644 index 000000000..f80363cdb --- /dev/null +++ b/src/fastmcp/server/plugins/auth/auth0/__init__.py @@ -0,0 +1,5 @@ +"""Auth0 auth plugin.""" + +from fastmcp.server.plugins.auth.auth0.plugin import Auth0Auth + +__all__ = ["Auth0Auth"] diff --git a/src/fastmcp/server/plugins/auth/auth0/plugin.py b/src/fastmcp/server/plugins/auth/auth0/plugin.py new file mode 100644 index 000000000..accacf678 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/auth0/plugin.py @@ -0,0 +1,63 @@ +"""Auth0 auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import AuthProvider +from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProxyConfig +from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider +from fastmcp.server.plugins.base import PluginMeta + + +class Auth0AuthConfig(OAuthProxyConfig): + """Config model for the Auth0 auth plugin.""" + + config_url: AnyHttpUrl | str | None = None + client_id: str | None = None + client_secret: str | None = None + audience: str | None = None + + +class Auth0Auth(AuthPlugin[Auth0AuthConfig]): + """Contribute an `Auth0Provider` as the server's auth provider.""" + + Config: ClassVar[type[Auth0AuthConfig]] = Auth0AuthConfig + + meta = PluginMeta(name="auth0-auth") + + def __init__( + self, + config: Auth0AuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + + def auth(self) -> AuthProvider | None: + self._require( + "config_url", "client_id", "client_secret", "audience", "base_url" + ) + return Auth0Provider( + **self._kwargs( + "config_url", + "client_id", + "client_secret", + "audience", + "base_url", + "resource_base_url", + "issuer_url", + "required_scopes", + "redirect_path", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + ), + client_storage=self._client_storage, + ) diff --git a/src/fastmcp/server/plugins/auth/auth0/provider.py b/src/fastmcp/server/plugins/auth/auth0/provider.py new file mode 100644 index 000000000..ab1abe6fa --- /dev/null +++ b/src/fastmcp/server/plugins/auth/auth0/provider.py @@ -0,0 +1,135 @@ +"""Auth0 OAuth provider for FastMCP. + +This module provides a complete Auth0 integration that's ready to use with +just the configuration URL, client ID, client secret, audience, and base URL. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider + + # Simple Auth0 OAuth protection + auth = Auth0Provider( + config_url="https://auth0.config.url", + client_id="your-auth0-client-id", + client_secret="your-auth0-client-secret", + audience="your-auth0-api-audience", + base_url="http://localhost:8000", + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` +""" + +from typing import Literal + +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth.oidc_proxy import OIDCProxy +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class Auth0Provider(OIDCProxy): + """An Auth0 provider implementation for FastMCP. + + This provider is a complete Auth0 integration that's ready to use with + just the configuration URL, client ID, client secret, audience, and base URL. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider + + # Simple Auth0 OAuth protection + auth = Auth0Provider( + config_url="https://auth0.config.url", + client_id="your-auth0-client-id", + client_secret="your-auth0-client-secret", + audience="your-auth0-api-audience", + base_url="http://localhost:8000", + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` + """ + + def __init__( + self, + *, + config_url: AnyHttpUrl | str, + client_id: str, + client_secret: str, + audience: str, + base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, + issuer_url: AnyHttpUrl | str | None = None, + required_scopes: list[str] | None = None, + redirect_path: str | None = None, + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | None = None, + require_authorization_consent: bool | Literal["remember", "external"] = True, + consent_csp_policy: str | None = None, + forward_resource: bool = True, + ) -> None: + """Initialize Auth0 OAuth provider. + + Args: + config_url: Auth0 config URL + client_id: Auth0 application client id + client_secret: Auth0 application client secret + audience: Auth0 API audience + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL + to avoid 404s during discovery when mounting under a path. + required_scopes: Required Auth0 scopes (defaults to ["openid"]) + redirect_path: Redirect path configured in Auth0 application + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). + jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, + they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not + provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. + require_authorization_consent: Whether to require user consent before authorizing clients (default True). + When True, users see a consent screen before being redirected to Auth0. + When False, authorization proceeds directly without user confirmation. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. + """ + # Parse scopes if provided as string + auth0_required_scopes = ( + parse_scopes(required_scopes) if required_scopes is not None else ["openid"] + ) + + super().__init__( + config_url=config_url, + client_id=client_id, + client_secret=client_secret, + audience=audience, + base_url=base_url, + resource_base_url=resource_base_url, + issuer_url=issuer_url, + redirect_path=redirect_path, + required_scopes=auth0_required_scopes, + allowed_client_redirect_uris=allowed_client_redirect_uris, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + ) + + logger.debug( + "Initialized Auth0 OAuth provider for client %s with scopes: %s", + client_id, + auth0_required_scopes, + ) diff --git a/src/fastmcp/server/plugins/auth/authkit/__init__.py b/src/fastmcp/server/plugins/auth/authkit/__init__.py new file mode 100644 index 000000000..6fa1b75ac --- /dev/null +++ b/src/fastmcp/server/plugins/auth/authkit/__init__.py @@ -0,0 +1,5 @@ +"""WorkOS AuthKit auth plugin.""" + +from fastmcp.server.plugins.auth.authkit.plugin import AuthKitAuth + +__all__ = ["AuthKitAuth"] diff --git a/src/fastmcp/server/plugins/auth/authkit/plugin.py b/src/fastmcp/server/plugins/auth/authkit/plugin.py new file mode 100644 index 000000000..38fc8cb11 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/authkit/plugin.py @@ -0,0 +1,51 @@ +"""WorkOS AuthKit auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import AuthProvider, TokenVerifier +from fastmcp.server.plugins.auth._base import AuthPlugin, RemoteAuthConfig +from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider +from fastmcp.server.plugins.base import PluginMeta + + +class AuthKitAuthConfig(RemoteAuthConfig): + """Config model for the WorkOS AuthKit auth plugin.""" + + authkit_domain: AnyHttpUrl | str | None = None + resource_base_url: AnyHttpUrl | str | None = None + + +class AuthKitAuth(AuthPlugin[AuthKitAuthConfig]): + """Contribute an `AuthKitProvider` as the server's auth provider.""" + + Config: ClassVar[type[AuthKitAuthConfig]] = AuthKitAuthConfig + + meta = PluginMeta(name="authkit-auth") + + def __init__( + self, + config: AuthKitAuthConfig | dict[str, Any] | None = None, + *, + token_verifier: TokenVerifier | None = None, + ) -> None: + super().__init__(config) + self._token_verifier = token_verifier + + def auth(self) -> AuthProvider | None: + self._require("authkit_domain", "base_url") + return AuthKitProvider( + **self._kwargs( + "authkit_domain", + "base_url", + "resource_base_url", + "required_scopes", + "scopes_supported", + "resource_name", + "resource_documentation", + ), + token_verifier=self._token_verifier, + ) diff --git a/src/fastmcp/server/plugins/auth/authkit/provider.py b/src/fastmcp/server/plugins/auth/authkit/provider.py new file mode 100644 index 000000000..de6498278 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/authkit/provider.py @@ -0,0 +1,186 @@ +"""WorkOS AuthKit provider.""" + +from __future__ import annotations + +import httpx +from pydantic import AnyHttpUrl +from starlette.responses import JSONResponse +from starlette.routing import Route + +from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class AuthKitProvider(RemoteAuthProvider): + """AuthKit metadata provider for DCR (Dynamic Client Registration). + + This provider implements AuthKit integration using metadata forwarding + instead of OAuth proxying. This is the recommended approach for WorkOS DCR + as it allows WorkOS to handle the OAuth flow directly while FastMCP acts + as a resource server. + + IMPORTANT SETUP REQUIREMENTS: + + 1. Enable Dynamic Client Registration in WorkOS Dashboard: + - Go to Applications -> Configuration + - Toggle "Dynamic Client Registration" to enabled + + 2. Configure your FastMCP server URL as a callback: + - Add your server URL to the Redirects tab in WorkOS dashboard + - Example: https://your-fastmcp-server.com/oauth2/callback + + For detailed setup instructions, see: + https://workos.com/docs/authkit/mcp/integrating/token-verification + + Token audience is bound to this server automatically: when the MCP + mount path becomes known (typically at ``http_app()`` construction), + ``JWTVerifier.audience`` is set to the resource URL advertised in + ``.well-known/oauth-protected-resource``. Enable Resource Indicators + (RFC 8707) in your WorkOS Dashboard and list that same URL — AuthKit + will then mint tokens with the matching ``aud`` claim. + + Example: + ```python + from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider + + workos_auth = AuthKitProvider( + authkit_domain="https://your-workos-domain.authkit.app", + base_url="https://your-fastmcp-server.com", + ) + + mcp = FastMCP("My App", auth=workos_auth) + ``` + """ + + def __init__( + self, + *, + authkit_domain: AnyHttpUrl | str, + base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, + required_scopes: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, + token_verifier: TokenVerifier | None = None, + ): + """Initialize AuthKit metadata provider. + + Args: + authkit_domain: Your AuthKit domain (e.g., "https://your-app.authkit.app") + base_url: Public URL of this FastMCP server + resource_base_url: Optional public base URL for the protected resource. + When provided, this URL is advertised in protected resource metadata + instead of ``base_url``. Useful when OAuth callbacks and the protected + MCP resource live under different public URLs. + required_scopes: Optional list of scopes to require for all requests + scopes_supported: Optional list of scopes to advertise in OAuth metadata. + If None, uses required_scopes. Use this when the scopes clients should + request differ from the scopes enforced on tokens. + resource_name: Optional name for the protected resource metadata. + resource_documentation: Optional documentation URL for the protected resource. + token_verifier: Optional token verifier. If provided, it is used as-is and + audience auto-wiring is skipped — the caller is responsible for setting + an appropriate ``audience``. If None (default), a ``JWTVerifier`` is + created with audience bound to this server's resource URL. + """ + self.authkit_domain = str(authkit_domain).rstrip("/") + self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) + + parsed_scopes = ( + parse_scopes(required_scopes) if required_scopes is not None else None + ) + + # When no custom verifier is provided, we own the JWTVerifier and can + # bind its audience to our resource URL once set_mcp_path() is called. + self._auto_bind_audience = token_verifier is None + if token_verifier is None: + token_verifier = JWTVerifier( + jwks_uri=f"{self.authkit_domain}/oauth2/jwks", + issuer=self.authkit_domain, + algorithm="RS256", + required_scopes=parsed_scopes, + ) + + super().__init__( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl(self.authkit_domain)], + base_url=self.base_url, + resource_base_url=resource_base_url, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, + ) + + def set_mcp_path(self, mcp_path: str | None) -> None: + """Bind the default verifier's audience to this server's resource URL. + + AuthKit with Resource Indicators (RFC 8707) mints tokens whose ``aud`` + claim equals the resource URL the client requested — which is the URL + we advertise in ``.well-known/oauth-protected-resource``. Binding the + audience here keeps validation in lock-step with what clients are sent. + """ + super().set_mcp_path(mcp_path) + if ( + self._auto_bind_audience + and self._resource_url is not None + and isinstance(self.token_verifier, JWTVerifier) + ): + resource_url = str(self._resource_url) + self.token_verifier.audience = resource_url + logger.info( + "AuthKit tokens will be validated against aud=%s. " + "Configure this URL as a Resource Indicator in the WorkOS Dashboard.", + resource_url, + ) + + def get_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get OAuth routes including AuthKit authorization server metadata forwarding. + + This returns the standard protected resource routes plus an authorization server + metadata endpoint that forwards AuthKit's OAuth metadata to clients. + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + This is used to advertise the resource URL in metadata. + """ + routes = super().get_routes(mcp_path) + + async def oauth_authorization_server_metadata(request): + """Forward AuthKit OAuth authorization server metadata with FastMCP customizations.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.authkit_domain}/.well-known/oauth-authorization-server" + ) + response.raise_for_status() + metadata = response.json() + return JSONResponse(metadata) + except Exception as e: + return JSONResponse( + { + "error": "server_error", + "error_description": f"Failed to fetch AuthKit metadata: {e}", + }, + status_code=500, + ) + + routes.append( + Route( + "/.well-known/oauth-authorization-server", + endpoint=oauth_authorization_server_metadata, + methods=["GET"], + ) + ) + + return routes + + +__all__ = ["AuthKitProvider"] diff --git a/src/fastmcp/server/plugins/auth/aws/__init__.py b/src/fastmcp/server/plugins/auth/aws/__init__.py new file mode 100644 index 000000000..d9f6677ff --- /dev/null +++ b/src/fastmcp/server/plugins/auth/aws/__init__.py @@ -0,0 +1,5 @@ +"""AWS Cognito auth plugin.""" + +from fastmcp.server.plugins.auth.aws.plugin import AWSCognitoAuth + +__all__ = ["AWSCognitoAuth"] diff --git a/src/fastmcp/server/plugins/auth/aws/plugin.py b/src/fastmcp/server/plugins/auth/aws/plugin.py new file mode 100644 index 000000000..922c3485a --- /dev/null +++ b/src/fastmcp/server/plugins/auth/aws/plugin.py @@ -0,0 +1,61 @@ +"""AWS Cognito auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from key_value.aio.protocols import AsyncKeyValue + +from fastmcp.server.auth import AuthProvider +from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProxyConfig +from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider +from fastmcp.server.plugins.base import PluginMeta + + +class AWSCognitoAuthConfig(OAuthProxyConfig): + """Config model for the AWS Cognito auth plugin.""" + + user_pool_id: str | None = None + client_id: str | None = None + client_secret: str | None = None + aws_region: str = "eu-central-1" + redirect_path: str | None = "/auth/callback" + + +class AWSCognitoAuth(AuthPlugin[AWSCognitoAuthConfig]): + """Contribute an `AWSCognitoProvider` as the server's auth provider.""" + + Config: ClassVar[type[AWSCognitoAuthConfig]] = AWSCognitoAuthConfig + + meta = PluginMeta(name="aws-cognito-auth") + + def __init__( + self, + config: AWSCognitoAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + + def auth(self) -> AuthProvider | None: + self._require("user_pool_id", "client_id", "client_secret", "base_url") + return AWSCognitoProvider( + **self._kwargs( + "user_pool_id", + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "aws_region", + "issuer_url", + "redirect_path", + "required_scopes", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + ), + client_storage=self._client_storage, + ) diff --git a/src/fastmcp/server/plugins/auth/aws/provider.py b/src/fastmcp/server/plugins/auth/aws/provider.py new file mode 100644 index 000000000..6e8dbb74e --- /dev/null +++ b/src/fastmcp/server/plugins/auth/aws/provider.py @@ -0,0 +1,229 @@ +"""AWS Cognito OAuth provider for FastMCP. + +This module provides a complete AWS Cognito OAuth integration that's ready to use +with a user pool ID, domain prefix, client ID and client secret. It handles all +the complexity of AWS Cognito's OAuth flow, token validation, and user management. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider + + # Simple AWS Cognito OAuth protection + auth = AWSCognitoProvider( + user_pool_id="your-user-pool-id", + aws_region="eu-central-1", + client_id="your-cognito-client-id", + client_secret="your-cognito-client-secret" + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` +""" + +from __future__ import annotations + +from typing import Literal + +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.auth.oidc_proxy import OIDCProxy +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class AWSCognitoTokenVerifier(JWTVerifier): + """Token verifier for Cognito access tokens. + + Cognito access tokens use a ``client_id`` claim instead of the + standard ``aud`` claim. This subclass passes ``audience=None`` + to the parent (skipping the ``aud`` check) and validates the + ``client_id`` claim directly. + """ + + def __init__(self, *, audience: str | list[str] | None = None, **kwargs): + self._expected_client_id = audience + super().__init__(audience=None, **kwargs) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token and filter claims to Cognito-specific subset.""" + access_token = await super().verify_token(token) + if not access_token: + return None + + # Validate client_id claim (Cognito's equivalent of aud) + if self._expected_client_id: + token_client_id = access_token.claims.get("client_id") + if isinstance(self._expected_client_id, list): + valid = token_client_id in self._expected_client_id + else: + valid = token_client_id == self._expected_client_id + if not valid: + self.logger.debug( + "Token validation failed: client_id mismatch (expected %s, got %s)", + self._expected_client_id, + token_client_id, + ) + return None + + # Filter claims to Cognito-specific subset + cognito_claims = { + "sub": access_token.claims.get("sub"), + "username": access_token.claims.get("username"), + "cognito:groups": access_token.claims.get("cognito:groups", []), + } + + return AccessToken( + token=access_token.token, + client_id=access_token.client_id, + scopes=access_token.scopes, + expires_at=access_token.expires_at, + claims=cognito_claims, + ) + + +class AWSCognitoProvider(OIDCProxy): + """Complete AWS Cognito OAuth provider for FastMCP. + + This provider makes it trivial to add AWS Cognito OAuth protection to any + FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details, + client credentials, and a base URL, and you're ready to go. + + Features: + - Automatic OIDC Discovery from AWS Cognito User Pool + - Automatic JWT token validation via Cognito's public keys + - Cognito-specific claim filtering (sub, username, cognito:groups) + - Support for Cognito User Pools + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider + + auth = AWSCognitoProvider( + user_pool_id="eu-central-1_XXXXXXXXX", + aws_region="eu-central-1", + client_id="your-cognito-client-id", + client_secret="your-cognito-client-secret", + base_url="https://my-server.com", + redirect_path="/custom/callback", + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + user_pool_id: str, + client_id: str, + client_secret: str, + base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, + aws_region: str = "eu-central-1", + issuer_url: AnyHttpUrl | str | None = None, + redirect_path: str = "/auth/callback", + required_scopes: list[str] | None = None, + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | None = None, + require_authorization_consent: bool | Literal["remember", "external"] = True, + consent_csp_policy: str | None = None, + forward_resource: bool = True, + ): + """Initialize AWS Cognito OAuth provider. + + Args: + user_pool_id: Your Cognito User Pool ID (e.g., "eu-central-1_XXXXXXXXX") + client_id: Cognito app client ID + client_secret: Cognito app client secret + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. + aws_region: AWS region where your User Pool is located (defaults to "eu-central-1") + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL + to avoid 404s during discovery when mounting under a path. + redirect_path: Redirect path configured in Cognito app (defaults to "/auth/callback") + required_scopes: Required Cognito scopes (defaults to ["openid"]) + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). + jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, + they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not + provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. + require_authorization_consent: Whether to require user consent before authorizing clients (default True). + When True, users see a consent screen before being redirected to AWS Cognito. + When False, authorization proceeds directly without user confirmation. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. + """ + # Parse scopes if provided as string + required_scopes_final = ( + parse_scopes(required_scopes) if required_scopes is not None else ["openid"] + ) + + # Construct OIDC discovery URL + config_url = f"https://cognito-idp.{aws_region}.amazonaws.com/{user_pool_id}/.well-known/openid-configuration" + + # Store Cognito-specific info for claim filtering + self.user_pool_id = user_pool_id + self.aws_region = aws_region + self.client_id = client_id + + # Initialize OIDC proxy with Cognito discovery + super().__init__( + config_url=config_url, + client_id=client_id, + client_secret=client_secret, + algorithm="RS256", + required_scopes=required_scopes_final, + base_url=base_url, + resource_base_url=resource_base_url, + issuer_url=issuer_url, + redirect_path=redirect_path, + allowed_client_redirect_uris=allowed_client_redirect_uris, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + ) + + logger.debug( + "Initialized AWS Cognito OAuth provider for client %s with scopes: %s", + client_id, + required_scopes_final, + ) + + def get_token_verifier( + self, + *, + algorithm: str | None = None, + audience: str | None = None, + required_scopes: list[str] | None = None, + timeout_seconds: int | None = None, + ) -> AWSCognitoTokenVerifier: + """Creates a Cognito-specific token verifier with claim filtering. + + Args: + algorithm: Optional token verifier algorithm + audience: Optional token verifier audience + required_scopes: Optional token verifier required_scopes + timeout_seconds: HTTP request timeout in seconds + """ + return AWSCognitoTokenVerifier( + issuer=str(self.oidc_config.issuer), + audience=audience or self.client_id, + algorithm=algorithm, + jwks_uri=str(self.oidc_config.jwks_uri), + required_scopes=required_scopes, + ) diff --git a/src/fastmcp/server/plugins/auth/azure/__init__.py b/src/fastmcp/server/plugins/auth/azure/__init__.py new file mode 100644 index 000000000..c15183a2b --- /dev/null +++ b/src/fastmcp/server/plugins/auth/azure/__init__.py @@ -0,0 +1,5 @@ +"""Azure auth plugin.""" + +from fastmcp.server.plugins.auth.azure.plugin import AzureAuth + +__all__ = ["AzureAuth"] diff --git a/src/fastmcp/server/plugins/auth/azure/plugin.py b/src/fastmcp/server/plugins/auth/azure/plugin.py new file mode 100644 index 000000000..96e36274f --- /dev/null +++ b/src/fastmcp/server/plugins/auth/azure/plugin.py @@ -0,0 +1,69 @@ +"""Azure auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import httpx +from key_value.aio.protocols import AsyncKeyValue + +from fastmcp.server.auth import AuthProvider +from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig +from fastmcp.server.plugins.auth.azure.provider import AzureProvider +from fastmcp.server.plugins.base import PluginMeta + + +class AzureAuthConfig(OAuthProviderConfig): + """Config model for the Azure auth plugin.""" + + tenant_id: str | None = None + required_scopes: list[str] | None = None + identifier_uri: str | None = None + additional_authorize_scopes: list[str] | None = None + base_authority: str = "login.microsoftonline.com" + + +class AzureAuth(AuthPlugin[AzureAuthConfig]): + """Contribute an `AzureProvider` as the server's auth provider.""" + + Config: ClassVar[type[AzureAuthConfig]] = AzureAuthConfig + + meta = PluginMeta(name="azure-auth") + + def __init__( + self, + config: AzureAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("client_id", "tenant_id", "required_scopes", "base_url") + self._require_one("client_secret", "jwt_signing_key") + return AzureProvider( + **self._kwargs( + "client_id", + "client_secret", + "tenant_id", + "required_scopes", + "base_url", + "resource_base_url", + "identifier_uri", + "issuer_url", + "redirect_path", + "additional_authorize_scopes", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "base_authority", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) diff --git a/src/fastmcp/server/plugins/auth/azure/provider.py b/src/fastmcp/server/plugins/auth/azure/provider.py new file mode 100644 index 000000000..8854665cd --- /dev/null +++ b/src/fastmcp/server/plugins/auth/azure/provider.py @@ -0,0 +1,768 @@ +"""Azure (Microsoft Entra) OAuth provider for FastMCP. + +This provider implements Azure/Microsoft Entra ID OAuth authentication +using the OAuth Proxy pattern for non-DCR OAuth flows. +""" + +from __future__ import annotations + +import hashlib +from collections import OrderedDict +from typing import TYPE_CHECKING, Any, Literal, cast + +import httpx +from key_value.aio.protocols import AsyncKeyValue + +from fastmcp.dependencies import Dependency +from fastmcp.server.auth.auth import MultiAuth +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes +from fastmcp.utilities.logging import get_logger + +if TYPE_CHECKING: + from azure.identity.aio import OnBehalfOfCredential + from mcp.server.auth.provider import AuthorizationParams + from mcp.shared.auth import OAuthClientInformationFull + from pydantic import AnyHttpUrl + + from fastmcp.server.auth.auth import AuthProvider + +logger = get_logger(__name__) + +# Standard OIDC scopes that should never be prefixed with identifier_uri. +# Per Microsoft docs: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc +# "OIDC scopes are requested as simple string identifiers without resource prefixes" +OIDC_SCOPES = frozenset({"openid", "profile", "email", "offline_access"}) + + +class AzureProvider(OAuthProxy): + """Azure (Microsoft Entra) OAuth provider for FastMCP. + + This provider implements Azure/Microsoft Entra ID authentication using the + OAuth Proxy pattern. It supports both organizational accounts and personal + Microsoft accounts depending on the tenant configuration. + + Scope Handling: + - required_scopes: Provide unprefixed scope names (e.g., ["read", "write"]) + → Automatically prefixed with identifier_uri during initialization + → Validated on all tokens and advertised to MCP clients + - additional_authorize_scopes: Provide full format (e.g., ["User.Read"]) + → NOT prefixed, NOT validated, NOT advertised to clients + → Used to request Microsoft Graph or other upstream API permissions + + Features: + - OAuth proxy to Azure/Microsoft identity platform + - JWT validation using tenant issuer and JWKS + - Supports tenant configurations: specific tenant ID, "organizations", or "consumers" + - Custom API scopes and Microsoft Graph scopes in a single provider + + Setup: + 1. Create an App registration in Azure Portal + 2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path) + 3. Add an Application ID URI under "Expose an API" (defaults to api://{client_id}) + 4. Add custom scopes (e.g., "read", "write") under "Expose an API" + 5. Set access token version to 2 in the App manifest: "requestedAccessTokenVersion": 2 + 6. Create a client secret + 7. Get Application (client) ID, Directory (tenant) ID, and client secret + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.azure.provider import AzureProvider + + # Standard Azure (Public Cloud) + auth = AzureProvider( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + required_scopes=["read", "write"], # Unprefixed scope names + additional_authorize_scopes=["User.Read", "Mail.Read"], # Optional Graph scopes + base_url="http://localhost:8000", + # identifier_uri defaults to api://{client_id} + ) + + # Azure Government + auth_gov = AzureProvider( + client_id="your-client-id", + client_secret="your-client-secret", + tenant_id="your-tenant-id", + required_scopes=["read", "write"], + base_authority="login.microsoftonline.us", # Override for Azure Gov + base_url="http://localhost:8000", + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + client_id: str, + client_secret: str | None = None, + tenant_id: str, + required_scopes: list[str], + base_url: str, + resource_base_url: AnyHttpUrl | str | None = None, + identifier_uri: str | None = None, + issuer_url: str | None = None, + redirect_path: str | None = None, + additional_authorize_scopes: list[str] | None = None, + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | None = None, + require_authorization_consent: bool | Literal["remember", "external"] = True, + consent_csp_policy: str | None = None, + forward_resource: bool = True, + base_authority: str = "login.microsoftonline.com", + http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, + ) -> None: + """Initialize Azure OAuth provider. + + Args: + client_id: Azure application (client) ID from your App registration + client_secret: Azure client secret from your App registration. Optional when + using alternative credentials (e.g., managed identity with a custom + _create_upstream_oauth_client override). When omitted, jwt_signing_key + must be provided. + tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers") + identifier_uri: Optional Application ID URI for your custom API (defaults to api://{client_id}). + This URI is automatically prefixed to all required_scopes during initialization. + Example: identifier_uri="api://my-api" + required_scopes=["read"] + → tokens validated for "api://my-api/read" + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL + to avoid 404s during discovery when mounting under a path. + redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback") + base_authority: Azure authority base URL (defaults to "login.microsoftonline.com"). + For Azure Government, use "login.microsoftonline.us". + required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]). + - Automatically prefixed with identifier_uri during initialization + - Validated on all tokens + - Advertised in Protected Resource Metadata + - Must match scope names defined in Azure Portal under "Expose an API" + Example: ["read", "write"] → validates tokens containing ["api://xxx/read", "api://xxx/write"] + additional_authorize_scopes: Microsoft Graph or other upstream scopes in full format. + - NOT prefixed with identifier_uri + - NOT validated on tokens + - NOT advertised to MCP clients + - Used to request additional permissions from Azure (e.g., Graph API access) + Example: ["User.Read", "Mail.Read"] + These scopes allow your FastMCP server to call Microsoft Graph APIs using the + upstream Azure token, but MCP clients are unaware of them. + Note: "offline_access" is automatically included to obtain refresh tokens. + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). + jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, + they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not + provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. + require_authorization_consent: Whether to require user consent before authorizing clients (default True). + When True, users see a consent screen before being redirected to Azure. + When False, authorization proceeds directly without user confirmation. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. + http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches. + When provided, the client is reused for JWT key fetches and the caller + is responsible for its lifecycle. When None (default), a fresh client is created per fetch. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. + """ + # Parse scopes if provided as string + parsed_required_scopes = parse_scopes(required_scopes) + parsed_additional_scopes: list[str] = ( + parse_scopes(additional_authorize_scopes) or [] + if additional_authorize_scopes + else [] + ) + + # Always include offline_access to get refresh tokens from Azure + if "offline_access" not in parsed_additional_scopes: + parsed_additional_scopes = [*parsed_additional_scopes, "offline_access"] + + # Store Azure-specific config for OBO credential creation + self._tenant_id = tenant_id + self._base_authority = base_authority + + # Cache of OBO credentials keyed by hash of user assertion token. + # Reusing credentials allows the Azure SDK's internal token cache + # to avoid redundant OBO exchanges for the same user + scopes. + self._obo_credentials: OrderedDict[str, OnBehalfOfCredential] = OrderedDict() + self._obo_max_credentials: int = 128 + + # Apply defaults + self.identifier_uri = identifier_uri or f"api://{client_id}" + self.additional_authorize_scopes: list[str] = parsed_additional_scopes + + # Always validate tokens against the app's API client ID using JWT + issuer = f"https://{base_authority}/{tenant_id}/v2.0" + jwks_uri = f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys" + + # Azure access tokens only include custom API scopes in the `scp` claim, + # NOT standard OIDC scopes (openid, profile, email, offline_access). + # Filter out OIDC scopes from validation - they'll still be sent to Azure + # during authorization (handled by _prefix_scopes_for_azure). + validation_scopes = [ + s for s in (parsed_required_scopes or []) if s not in OIDC_SCOPES + ] + if not validation_scopes: + raise ValueError( + "AzureProvider requires at least one non-OIDC scope in " + "required_scopes (e.g., 'read', 'write'). OIDC scopes like " + "'openid', 'profile', 'email', and 'offline_access' are not " + "included in Azure access token claims and cannot be used for " + "scope enforcement." + ) + + token_verifier = JWTVerifier( + jwks_uri=jwks_uri, + issuer=issuer, + audience=[client_id, self.identifier_uri], + algorithm="RS256", + required_scopes=validation_scopes, # Only validate non-OIDC scopes + http_client=http_client, + ) + + # Build Azure OAuth endpoints with tenant + authorization_endpoint = ( + f"https://{base_authority}/{tenant_id}/oauth2/v2.0/authorize" + ) + token_endpoint = f"https://{base_authority}/{tenant_id}/oauth2/v2.0/token" + + # Initialize OAuth proxy with Azure endpoints + # Remember there's hooks called, such as _prepare_scopes_for_token_exchange + # and _prepare_scopes_for_upstream_refresh + super().__init__( + upstream_authorization_endpoint=authorization_endpoint, + upstream_token_endpoint=token_endpoint, + upstream_client_id=client_id, + upstream_client_secret=client_secret, + token_verifier=token_verifier, + base_url=base_url, + resource_base_url=resource_base_url, + redirect_path=redirect_path, + issuer_url=issuer_url or base_url, # Default to base_url if not specified + allowed_client_redirect_uris=allowed_client_redirect_uris, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + valid_scopes=parsed_required_scopes, + enable_cimd=enable_cimd, + ) + + authority_info = "" + if base_authority != "login.microsoftonline.com": + authority_info = f" using authority {base_authority}" + logger.info( + "Initialized Azure OAuth provider for client %s with tenant %s%s%s", + client_id, + tenant_id, + f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "", + authority_info, + ) + + async def authorize( + self, + client: OAuthClientInformationFull, + params: AuthorizationParams, + ) -> str: + """Start OAuth transaction and redirect to Azure AD. + + Override parent's authorize method to filter out the 'resource' parameter + which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use + scopes to determine the resource/audience instead of a separate parameter. + + Args: + client: OAuth client information + params: Authorization parameters from the client + + Returns: + Authorization URL to redirect the user to Azure AD + """ + # Clear the resource parameter that Azure AD v2.0 doesn't support + # This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators) + # but Azure AD v2.0 uses scopes instead to determine the audience + params_to_use = params + if hasattr(params, "resource"): + original_resource = getattr(params, "resource", None) + if original_resource is not None: + params_to_use = params.model_copy(update={"resource": None}) + if original_resource: + logger.debug( + "Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)", + original_resource, + ) + # Don't modify the scopes in params - they stay unprefixed for MCP clients + # We'll prefix them when building the Azure authorization URL (in _build_upstream_authorize_url) + auth_url = await super().authorize(client, params_to_use) + separator = "&" if "?" in auth_url else "?" + return f"{auth_url}{separator}prompt=select_account" + + def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]: + """Prefix unprefixed custom API scopes with identifier_uri for Azure. + + This helper centralizes the scope prefixing logic used in both + authorization and token refresh flows. + + Scopes that are NOT prefixed: + - Standard OIDC scopes (openid, profile, email, offline_access) + - Fully-qualified URIs (contain "://") + - Scopes with path component (contain "/") + + Note: Microsoft Graph scopes (e.g., User.Read) should be passed via + `additional_authorize_scopes` or use fully-qualified format + (e.g., https://graph.microsoft.com/User.Read). + + Args: + scopes: List of scopes, may be prefixed or unprefixed + + Returns: + List of scopes with identifier_uri prefix applied where needed + """ + prefixed = [] + for scope in scopes: + if scope in OIDC_SCOPES: + # Standard OIDC scopes - never prefix + prefixed.append(scope) + elif "://" in scope or "/" in scope: + # Already fully-qualified (e.g., "api://xxx/read" or + # "https://graph.microsoft.com/User.Read") + prefixed.append(scope) + else: + # Unprefixed custom API scope - prefix with identifier_uri + prefixed.append(f"{self.identifier_uri}/{scope}") + return prefixed + + def _build_upstream_authorize_url( + self, txn_id: str, transaction: dict[str, Any] + ) -> str: + """Build Azure authorization URL with prefixed scopes. + + Overrides parent to prefix scopes with identifier_uri before sending to Azure, + while keeping unprefixed scopes in the transaction for MCP clients. + """ + # Get unprefixed scopes from transaction + unprefixed_scopes = transaction.get("scopes") or self.required_scopes or [] + + # Prefix scopes for Azure authorization request + prefixed_scopes = self._prefix_scopes_for_azure(unprefixed_scopes) + + # Add Microsoft Graph scopes (not validated, not prefixed) + if self.additional_authorize_scopes: + prefixed_scopes.extend(self.additional_authorize_scopes) + + # Temporarily modify transaction dict for parent's URL building + modified_transaction = transaction.copy() + modified_transaction["scopes"] = prefixed_scopes + + # Let parent build the URL with prefixed scopes + return super()._build_upstream_authorize_url(txn_id, modified_transaction) + + def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]: + """Prepare scopes for Azure authorization code exchange. + + Azure requires scopes during token exchange (AADSTS28003 error if missing). + Azure only allows ONE resource per token request (AADSTS28000), so we only + include scopes for this API plus OIDC scopes. + + Args: + scopes: Scopes from the authorization request (unprefixed) + + Returns: + List of scopes for Azure token endpoint + """ + # Prefix scopes for this API + prefixed_scopes = self._prefix_scopes_for_azure(scopes or []) + + # Add OIDC scopes only (not other API scopes) to avoid AADSTS28000 + if self.additional_authorize_scopes: + prefixed_scopes.extend( + s for s in self.additional_authorize_scopes if s in OIDC_SCOPES + ) + + deduplicated = list(dict.fromkeys(prefixed_scopes)) + logger.debug("Token exchange scopes: %s", deduplicated) + return deduplicated + + def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]: + """Prepare scopes for Azure token refresh. + + Azure requires fully-qualified scopes and only allows ONE resource per + token request (AADSTS28000). We include scopes for this API plus OIDC scopes. + + Args: + scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"]) + + Returns: + Deduplicated list of scopes formatted for Azure token endpoint + """ + logger.debug("Base scopes from storage: %s", scopes) + + # Filter out any additional_authorize_scopes that may have been stored + additional_scopes_set = set(self.additional_authorize_scopes or []) + base_scopes = [s for s in scopes if s not in additional_scopes_set] + + # Prefix base scopes with identifier_uri for Azure + prefixed_scopes = self._prefix_scopes_for_azure(base_scopes) + + # Add OIDC scopes only (not other API scopes) to avoid AADSTS28000 + if self.additional_authorize_scopes: + prefixed_scopes.extend( + s for s in self.additional_authorize_scopes if s in OIDC_SCOPES + ) + + deduplicated_scopes = list(dict.fromkeys(prefixed_scopes)) + logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes) + return deduplicated_scopes + + async def _extract_upstream_claims( + self, idp_tokens: dict[str, Any] + ) -> dict[str, Any] | None: + """Extract claims from Azure token response to embed in FastMCP JWT. + + Decodes the Azure access token (which is a JWT) to extract user identity + claims. This allows gateways to inspect upstream identity information by + decoding the FastMCP JWT without needing server-side storage lookups. + + Azure access tokens contain claims like: + - sub: Subject identifier (unique per user per application) + - oid: Object ID (unique user identifier across Azure AD) + - tid: Tenant ID + - azp: Authorized party (client ID that requested the token) + - name: Display name + - given_name: First name + - family_name: Last name + - preferred_username: User principal name (email format) + - upn: User Principal Name + - email: Email address (if available) + - roles: Application roles assigned to the user + - groups: Group memberships (if configured) + + Args: + idp_tokens: Full token response from Azure, containing access_token + and potentially id_token. + + Returns: + Dict of extracted claims, or None if extraction fails. + """ + access_token = idp_tokens.get("access_token") + if not access_token: + return None + + try: + # Azure access tokens are JWTs - decode without verification + # (already validated by token_verifier during token exchange) + payload = decode_jwt_payload(access_token) + + # Extract useful identity claims + claims: dict[str, Any] = {} + claim_keys = [ + "sub", + "oid", + "tid", + "azp", + "name", + "given_name", + "family_name", + "preferred_username", + "upn", + "email", + "roles", + "groups", + ] + for claim in claim_keys: + if claim in payload: + claims[claim] = payload[claim] + + if claims: + logger.debug( + "Extracted %d Azure claims for embedding in FastMCP JWT", + len(claims), + ) + return claims + + return None + + except Exception as e: + logger.debug("Failed to extract Azure claims: %s", e) + return None + + async def get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential: + """Get a cached or new OnBehalfOfCredential for OBO token exchange. + + Credentials are cached by user assertion so the Azure SDK's internal + token cache can avoid redundant OBO exchanges when the same user + calls multiple tools with the same scopes. + + Args: + user_assertion: The user's access token to exchange via OBO. + + Returns: + A configured OnBehalfOfCredential ready for get_token() calls. + + Raises: + ImportError: If azure-identity is not installed (requires fastmcp[azure]). + """ + _require_azure_identity("OBO token exchange") + from azure.identity.aio import OnBehalfOfCredential + + key = hashlib.sha256(user_assertion.encode()).hexdigest() + + if key in self._obo_credentials: + self._obo_credentials.move_to_end(key) + return self._obo_credentials[key] + + obo_kwargs: dict[str, Any] = { + "tenant_id": self._tenant_id, + "client_id": self._upstream_client_id, + "user_assertion": user_assertion, + "authority": f"https://{self._base_authority}", + } + if self._upstream_client_secret is not None: + obo_kwargs["client_secret"] = ( + self._upstream_client_secret.get_secret_value() + ) + else: + raise ValueError( + "OBO token exchange requires either a client_secret or a subclass " + "that overrides get_obo_credential() to provide alternative credentials " + "(e.g., client_assertion_func for managed identity)." + ) + credential = OnBehalfOfCredential(**obo_kwargs) + self._obo_credentials[key] = credential + + # Evict oldest if over capacity + while len(self._obo_credentials) > self._obo_max_credentials: + _, evicted = self._obo_credentials.popitem(last=False) + await evicted.close() + + return credential + + async def close_obo_credentials(self) -> None: + """Close all cached OBO credentials.""" + credentials = list(self._obo_credentials.values()) + self._obo_credentials.clear() + for credential in credentials: + try: + await credential.close() + except Exception: + logger.debug("Error closing OBO credential", exc_info=True) + + +class AzureJWTVerifier(JWTVerifier): + """JWT verifier pre-configured for Azure AD / Microsoft Entra ID. + + Auto-configures JWKS URI, issuer, audience, and scope handling from your + Azure app registration details. Designed for Managed Identity and other + token-verification-only scenarios where AzureProvider's full OAuth proxy + isn't needed. + + Handles Azure's scope format automatically: + - Validates tokens using short-form scopes (what Azure puts in ``scp`` claims) + - Advertises full-URI scopes in OAuth metadata (what clients need to request) + + Example:: + + from fastmcp.server.auth import RemoteAuthProvider + from fastmcp.server.plugins.auth.azure.provider import AzureJWTVerifier + from pydantic import AnyHttpUrl + + verifier = AzureJWTVerifier( + client_id="your-client-id", + tenant_id="your-tenant-id", + required_scopes=["access_as_user"], + ) + + auth = RemoteAuthProvider( + token_verifier=verifier, + authorization_servers=[ + AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0") + ], + base_url="https://my-server.com", + ) + """ + + def __init__( + self, + *, + client_id: str, + tenant_id: str, + required_scopes: list[str] | None = None, + identifier_uri: str | None = None, + base_authority: str = "login.microsoftonline.com", + ): + """Initialize Azure JWT verifier. + + Args: + client_id: Azure application (client) ID from your App registration + tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers"). + For multi-tenant apps ("organizations" or "consumers"), issuer validation + is skipped since Azure tokens carry the actual tenant GUID as issuer. + required_scopes: Scope names as they appear in Azure Portal under "Expose an API" + (e.g., ["access_as_user", "read"]). These are validated against + the short-form scopes in token ``scp`` claims, and automatically + prefixed with identifier_uri for OAuth metadata. + identifier_uri: Application ID URI (defaults to ``api://{client_id}``). + Used to prefix scopes in OAuth metadata so clients know the full + scope URIs to request from Azure. + base_authority: Azure authority base URL (defaults to "login.microsoftonline.com"). + For Azure Government, use "login.microsoftonline.us". + """ + self._identifier_uri = identifier_uri or f"api://{client_id}" + + # For multi-tenant apps, Azure tokens carry the actual tenant GUID as + # issuer, not the literal "organizations" or "consumers" string. Skip + # issuer validation for these — audience still protects against wrong-app tokens. + multi_tenant_values = {"organizations", "consumers", "common"} + issuer: str | None = ( + None + if tenant_id in multi_tenant_values + else f"https://{base_authority}/{tenant_id}/v2.0" + ) + + super().__init__( + jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys", + issuer=issuer, + audience=[client_id, self._identifier_uri], + algorithm="RS256", + required_scopes=required_scopes, + ) + + @property + def scopes_supported(self) -> list[str]: + """Return scopes with Azure URI prefix for OAuth metadata. + + Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp`` + claim, but clients must request full URI scopes (e.g., + ``api://client-id/read``) from the Azure authorization endpoint. This + property returns the full-URI form for OAuth metadata while + ``required_scopes`` retains the short form for token validation. + """ + if not self.required_scopes: + return [] + prefixed = [] + for scope in self.required_scopes: + if scope in OIDC_SCOPES or "://" in scope or "/" in scope: + prefixed.append(scope) + else: + prefixed.append(f"{self._identifier_uri}/{scope}") + return prefixed + + +# --- Dependency injection support --- +# These require fastmcp[azure] extra for azure-identity + + +def _require_azure_identity(feature: str) -> None: + """Raise ImportError with install instructions if azure-identity is not available.""" + try: + import azure.identity # noqa: F401 + except ImportError as e: + raise ImportError( + f"{feature} requires the `azure` extra. " + "Install with: pip install 'fastmcp[azure]'" + ) from e + + +def _find_azure_provider(auth: AuthProvider | None) -> AzureProvider | None: + """Extract an AzureProvider from an auth provider, unwrapping MultiAuth if needed.""" + if isinstance(auth, AzureProvider): + return auth + + if isinstance(auth, MultiAuth) and isinstance(auth.server, AzureProvider): + return auth.server + + return None + + +class _EntraOBOToken(Dependency[str]): + """Dependency that performs OBO token exchange for Microsoft Entra. + + Uses azure.identity's OnBehalfOfCredential for async-native OBO, + with automatic token caching and refresh. Credentials are cached on + the AzureProvider so repeated tool calls reuse existing credentials + and benefit from the Azure SDK's internal token cache. + """ + + def __init__(self, scopes: list[str]): + self.scopes = scopes + + async def __aenter__(self) -> str: + _require_azure_identity("EntraOBOToken") + + from fastmcp.server.dependencies import get_access_token, get_server + + access_token = get_access_token() + if access_token is None: + raise RuntimeError( + "No access token available. Cannot perform OBO exchange." + ) + + server = get_server() + azure_provider = _find_azure_provider(server.auth) + if azure_provider is None: + raise RuntimeError( + "EntraOBOToken requires an AzureProvider as the auth provider. " + f"Current provider: {type(server.auth).__name__}" + ) + + credential = await azure_provider.get_obo_credential( + user_assertion=access_token.token, + ) + + result = await credential.get_token(*self.scopes) + return result.token + + +def EntraOBOToken(scopes: list[str]) -> str: + """Exchange the user's Entra token for a downstream API token via OBO. + + This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange, + allowing your MCP server to call downstream APIs (like Microsoft Graph) on + behalf of the authenticated user. + + Args: + scopes: The scopes to request for the downstream API. For Microsoft Graph, + use scopes like ["https://graph.microsoft.com/Mail.Read"] or + ["https://graph.microsoft.com/.default"]. + + Returns: + A dependency that resolves to the downstream API access token string + + Raises: + ImportError: If fastmcp[azure] is not installed + RuntimeError: If no access token is available, provider is not Azure, + or OBO exchange fails + + Example: + ```python + from fastmcp.server.plugins.auth.azure.provider import EntraOBOToken + import httpx + + @mcp.tool() + async def get_my_emails( + graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]) + ): + async with httpx.AsyncClient() as client: + resp = await client.get( + "https://graph.microsoft.com/v1.0/me/messages", + headers={"Authorization": f"Bearer {graph_token}"} + ) + return resp.json() + ``` + + Note: + For OBO to work, ensure the scopes are included in the AzureProvider's + `additional_authorize_scopes` parameter, and that admin consent has been + granted for those scopes in your Entra app registration. + """ + return cast(str, _EntraOBOToken(scopes)) diff --git a/src/fastmcp/server/plugins/auth/clerk/__init__.py b/src/fastmcp/server/plugins/auth/clerk/__init__.py new file mode 100644 index 000000000..22b004aef --- /dev/null +++ b/src/fastmcp/server/plugins/auth/clerk/__init__.py @@ -0,0 +1,5 @@ +"""Clerk auth plugin.""" + +from fastmcp.server.plugins.auth.clerk.plugin import ClerkAuth + +__all__ = ["ClerkAuth"] diff --git a/src/fastmcp/server/plugins/auth/clerk/plugin.py b/src/fastmcp/server/plugins/auth/clerk/plugin.py new file mode 100644 index 000000000..f0ef4c4b1 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/clerk/plugin.py @@ -0,0 +1,67 @@ +"""Clerk auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import httpx +from key_value.aio.protocols import AsyncKeyValue + +from fastmcp.server.auth import AuthProvider +from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig +from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider +from fastmcp.server.plugins.base import PluginMeta + + +class ClerkAuthConfig(OAuthProviderConfig): + """Config model for the Clerk auth plugin.""" + + domain: str | None = None + valid_scopes: list[str] | None = None + extra_authorize_params: dict[str, str] | None = None + + +class ClerkAuth(AuthPlugin[ClerkAuthConfig]): + """Contribute a `ClerkProvider` as the server's auth provider.""" + + Config: ClassVar[type[ClerkAuthConfig]] = ClerkAuthConfig + + meta = PluginMeta(name="clerk-auth") + + def __init__( + self, + config: ClerkAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("domain", "client_id", "base_url") + self._require_one("client_secret", "jwt_signing_key") + return ClerkProvider( + **self._kwargs( + "domain", + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "issuer_url", + "redirect_path", + "required_scopes", + "valid_scopes", + "timeout_seconds", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "extra_authorize_params", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) diff --git a/src/fastmcp/server/plugins/auth/clerk/provider.py b/src/fastmcp/server/plugins/auth/clerk/provider.py new file mode 100644 index 000000000..b7888bb47 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/clerk/provider.py @@ -0,0 +1,388 @@ +"""Clerk OAuth provider for FastMCP. + +This module provides a complete Clerk OAuth integration that's ready to use +with a Clerk domain, client ID, and client secret. It handles all the complexity +of Clerk's OAuth/OIDC flow, token validation, and user management. + +Clerk uses standard OIDC endpoints derived from the instance domain +(e.g., ``https://.clerk.accounts.dev``). Token verification is +performed via the introspection endpoint (RFC 7662) for security-critical +checks (active status, audience, scopes), followed by the userinfo endpoint +for profile enrichment. Userinfo failure is non-fatal. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider + + auth = ClerkProvider( + domain="saving-primate-16.clerk.accounts.dev", + client_id="your-clerk-client-id", + client_secret="your-clerk-client-secret", + base_url="https://my-server.com", + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` +""" + +from __future__ import annotations + +import contextlib +from typing import Literal + +import httpx +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import TokenVerifier +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class ClerkTokenVerifier(TokenVerifier): + """Token verifier for Clerk OAuth tokens. + + Clerk issues standard OIDC tokens. Verification uses the introspection + endpoint (RFC 7662) as the primary security gate — it confirms the token + is active and provides metadata (scopes, expiry, audience). The userinfo + endpoint is called second for profile enrichment (name, email, picture) + and its failure is non-fatal. + + When a ``client_id`` is configured, the audience from introspection is + validated against it. When ``required_scopes`` are configured, + introspection must return the token's scopes — the verifier will not + assume scopes when introspection is unavailable. + """ + + def __init__( + self, + *, + domain: str, + client_id: str | None = None, + client_secret: str | None = None, + required_scopes: list[str] | None = None, + timeout_seconds: int = 10, + http_client: httpx.AsyncClient | None = None, + ): + """Initialize the Clerk token verifier. + + Args: + domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev") + client_id: Clerk OAuth client ID, used for introspection endpoint authentication + client_secret: Clerk OAuth client secret, used for introspection endpoint authentication + required_scopes: Required OAuth scopes (e.g., ["openid", "email", "profile"]) + timeout_seconds: HTTP request timeout + http_client: Optional httpx.AsyncClient for connection pooling. When provided, + the client is reused across calls and the caller is responsible for its + lifecycle. When None (default), a fresh client is created per call. + """ + super().__init__(required_scopes=required_scopes) + self.domain = domain.rstrip("/") + self._client_id = client_id + self._client_secret = client_secret + self.timeout_seconds = timeout_seconds + self._http_client = http_client + + self._userinfo_url = f"https://{self.domain}/oauth/userinfo" + self._introspection_url = f"https://{self.domain}/oauth/token_info" + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify a Clerk OAuth token via introspection and userinfo. + + Calls the introspection endpoint first to validate the token and + retrieve auth metadata (active status, scopes, expiry, audience). + If the token passes security checks, the userinfo endpoint is called + for profile enrichment. Userinfo failure is non-fatal. + + When a ``client_id`` is configured, the token's audience must match it. + When ``required_scopes`` are configured, introspection must confirm + them; tokens are rejected if scope information is unavailable. + """ + try: + async with ( + contextlib.nullcontext(self._http_client) + if self._http_client is not None + else httpx.AsyncClient(timeout=self.timeout_seconds) + ) as client: + # Step 1: Validate token via introspection (RFC 7662). + # Security-critical checks (active, audience, scopes) come first. + introspect_data_payload: dict = {"token": token} + introspect_kwargs: dict = { + "data": introspect_data_payload, + "headers": {"User-Agent": "FastMCP-Clerk-OAuth"}, + } + + if self._client_id and self._client_secret: + introspect_kwargs["auth"] = ( + self._client_id, + self._client_secret, + ) + elif self._client_id: + introspect_data_payload["client_id"] = self._client_id + + introspect_response = await client.post( + self._introspection_url, + **introspect_kwargs, + ) + + if introspect_response.status_code != 200: + logger.debug( + "Clerk introspection failed: %d", + introspect_response.status_code, + ) + return None + + introspect_data = introspect_response.json() + + # RFC 7662 requires the 'active' field in the response. + # A missing field indicates a malformed response — reject. + if "active" not in introspect_data or not introspect_data["active"]: + logger.debug( + "Clerk introspection: token inactive or missing 'active' field" + ) + return None + + scope_str = introspect_data.get("scope", "") + token_scopes = scope_str.split() if scope_str else [] + + aud = introspect_data.get("aud") or introspect_data.get("client_id") + + expires_at: int | None = None + exp = introspect_data.get("exp") + if exp is not None: + with contextlib.suppress(ValueError, TypeError): + expires_at = int(exp) + + if self._client_id and aud != self._client_id: + logger.debug( + "Clerk token audience mismatch: got %s, expected %s", + aud, + self._client_id, + ) + return None + + if self.required_scopes: + if not token_scopes: + logger.debug( + "Clerk token missing scope information; " + "cannot verify required scopes %s", + self.required_scopes, + ) + return None + token_scopes_set = set(token_scopes) + required_scopes_set = set(self.required_scopes) + if not required_scopes_set.issubset(token_scopes_set): + logger.debug( + "Clerk token missing required scopes. Has %s, needs %s", + token_scopes_set, + required_scopes_set, + ) + return None + + # Step 2: Fetch user profile via userinfo. + # Enriches the token with profile data (name, email, picture). + sub = introspect_data.get("sub") + user_data: dict = {} + try: + userinfo_response = await client.get( + self._userinfo_url, + headers={ + "Authorization": f"Bearer {token}", + "User-Agent": "FastMCP-Clerk-OAuth", + }, + ) + if userinfo_response.status_code == 200: + user_data = userinfo_response.json() + if not sub: + sub = user_data.get("sub") + except Exception as e: + logger.debug("Clerk userinfo call failed: %s", e) + + if not sub: + logger.debug("Clerk token missing 'sub' claim") + return None + + access_token = AccessToken( + token=token, + client_id=aud or sub, + scopes=token_scopes, + expires_at=expires_at, + claims={ + "sub": sub, + "aud": aud, + "email": user_data.get("email"), + "email_verified": user_data.get("email_verified"), + "name": user_data.get("name"), + "picture": user_data.get("picture"), + "given_name": user_data.get("given_name"), + "family_name": user_data.get("family_name"), + "preferred_username": user_data.get("preferred_username"), + "iss": user_data.get("iss"), + "clerk_user_data": user_data or None, + }, + ) + logger.debug("Clerk token verified successfully for sub=%s", sub) + return access_token + + except httpx.RequestError as e: + logger.debug("Failed to verify Clerk token: %s", e) + return None + except Exception as e: + logger.debug("Clerk token verification error: %s", e) + return None + + +class ClerkProvider(OAuthProxy): + """Complete Clerk OAuth provider for FastMCP. + + This provider makes it trivial to add Clerk OAuth protection to any + FastMCP server. Provide your Clerk instance domain, OAuth app credentials, + and a base URL, and you're ready to go. + + Clerk uses standard OIDC endpoints derived from the instance domain. + All endpoint URLs are constructed automatically from the domain parameter. + + Features: + - Transparent OAuth proxy to Clerk + - Automatic token validation via Clerk's userinfo & introspection APIs + - User information extraction from Clerk's OIDC claims + - PKCE support (S256) + - Minimal configuration required + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider + + auth = ClerkProvider( + domain="saving-primate-16.clerk.accounts.dev", + client_id="your-clerk-client-id", + client_secret="your-clerk-client-secret", + base_url="https://my-server.com", + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + domain: str, + client_id: str, + client_secret: str | None = None, + base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, + issuer_url: AnyHttpUrl | str | None = None, + redirect_path: str | None = None, + required_scopes: list[str] | None = None, + valid_scopes: list[str] | None = None, + timeout_seconds: int = 10, + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | None = None, + require_authorization_consent: bool | Literal["remember", "external"] = True, + consent_csp_policy: str | None = None, + forward_resource: bool = True, + extra_authorize_params: dict[str, str] | None = None, + http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, + ): + """Initialize Clerk OAuth provider. + + Args: + domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev"). + This is used to derive all OAuth/OIDC endpoint URLs. + client_id: Clerk OAuth application client ID + client_secret: Clerk OAuth application client secret. + Optional for PKCE public clients. When omitted, jwt_signing_key must be provided. + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL + to avoid 404s during discovery when mounting under a path. + redirect_path: Redirect path configured in Clerk OAuth app (defaults to "/auth/callback") + required_scopes: Required Clerk scopes (defaults to ["openid", "email", "profile"]). + Clerk supports: "openid", "email", "profile", "public_metadata", + "private_metadata", "offline_access". + valid_scopes: All scopes that clients are allowed to request, advertised through + well-known endpoints. Defaults to required_scopes if not provided. + timeout_seconds: HTTP request timeout for Clerk API calls (defaults to 10) + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). + If None, an encrypted file store will be created in the data directory + (derived from ``platformdirs``). + jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes + are provided, they will be used as is. If a string is provided, it will be derived + into a 32-byte key. If not provided, the upstream client secret will be used to + derive a 32-byte key using PBKDF2. + require_authorization_consent: Whether to require user consent before authorizing + clients (default True). When "external", the built-in consent screen is skipped + but no warning is logged, indicating that consent is handled externally by Clerk. + consent_csp_policy: Custom CSP policy for the consent page. + extra_authorize_params: Additional parameters to forward to Clerk's authorization + endpoint. Example: {"prompt": "login"} to force re-authentication. + http_client: Optional httpx.AsyncClient for connection pooling in token verification. + When provided, the client is reused across verify_token calls and the caller + is responsible for its lifecycle. When None (default), a fresh client is created + per call. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. + """ + domain = domain.rstrip("/") + + required_scopes_final = ( + parse_scopes(required_scopes) + if required_scopes is not None + else ["openid", "email", "profile"] + ) + + parsed_valid_scopes = ( + parse_scopes(valid_scopes) if valid_scopes is not None else None + ) + + token_verifier = ClerkTokenVerifier( + domain=domain, + client_id=client_id, + client_secret=client_secret, + required_scopes=required_scopes_final, + timeout_seconds=timeout_seconds, + http_client=http_client, + ) + + extra_authorize_params_final = ( + dict(extra_authorize_params) if extra_authorize_params else {} + ) + + super().__init__( + upstream_authorization_endpoint=f"https://{domain}/oauth/authorize", + upstream_token_endpoint=f"https://{domain}/oauth/token", + upstream_client_id=client_id, + upstream_client_secret=client_secret, + token_verifier=token_verifier, + base_url=base_url, + resource_base_url=resource_base_url, + redirect_path=redirect_path, + issuer_url=issuer_url or base_url, + allowed_client_redirect_uris=allowed_client_redirect_uris, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + extra_authorize_params=extra_authorize_params_final or None, + valid_scopes=parsed_valid_scopes, + enable_cimd=enable_cimd, + ) + + logger.debug( + "Initialized Clerk OAuth provider for domain %s with scopes: %s", + domain, + required_scopes_final, + ) diff --git a/src/fastmcp/server/plugins/auth/descope/__init__.py b/src/fastmcp/server/plugins/auth/descope/__init__.py new file mode 100644 index 000000000..14b30571b --- /dev/null +++ b/src/fastmcp/server/plugins/auth/descope/__init__.py @@ -0,0 +1,5 @@ +"""Descope auth plugin.""" + +from fastmcp.server.plugins.auth.descope.plugin import DescopeAuth + +__all__ = ["DescopeAuth"] diff --git a/src/fastmcp/server/plugins/auth/descope/plugin.py b/src/fastmcp/server/plugins/auth/descope/plugin.py new file mode 100644 index 000000000..9b3c9df48 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/descope/plugin.py @@ -0,0 +1,55 @@ +"""Descope auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import AuthProvider, TokenVerifier +from fastmcp.server.plugins.auth._base import AuthPlugin, RemoteAuthConfig +from fastmcp.server.plugins.auth.descope.provider import DescopeProvider +from fastmcp.server.plugins.base import PluginMeta + + +class DescopeAuthConfig(RemoteAuthConfig): + """Config model for the Descope auth plugin.""" + + config_url: AnyHttpUrl | str | None = None + project_id: str | None = None + descope_base_url: AnyHttpUrl | str | None = None + + +class DescopeAuth(AuthPlugin[DescopeAuthConfig]): + """Contribute a `DescopeProvider` as the server's auth provider.""" + + Config: ClassVar[type[DescopeAuthConfig]] = DescopeAuthConfig + + meta = PluginMeta(name="descope-auth") + + def __init__( + self, + config: DescopeAuthConfig | dict[str, Any] | None = None, + *, + token_verifier: TokenVerifier | None = None, + ) -> None: + super().__init__(config) + self._token_verifier = token_verifier + + def auth(self) -> AuthProvider | None: + self._require("base_url") + if self.config.config_url is None: + self._require("project_id", "descope_base_url") + return DescopeProvider( + **self._kwargs( + "base_url", + "config_url", + "project_id", + "descope_base_url", + "required_scopes", + "scopes_supported", + "resource_name", + "resource_documentation", + ), + token_verifier=self._token_verifier, + ) diff --git a/src/fastmcp/server/plugins/auth/descope/provider.py b/src/fastmcp/server/plugins/auth/descope/provider.py new file mode 100644 index 000000000..64fc7c021 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/descope/provider.py @@ -0,0 +1,209 @@ +"""Descope authentication provider for FastMCP. + +This module provides DescopeProvider - a complete authentication solution that integrates +with Descope's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR) +for seamless MCP client authentication. +""" + +from __future__ import annotations + +from urllib.parse import urlparse + +import httpx +from pydantic import AnyHttpUrl +from starlette.responses import JSONResponse +from starlette.routing import Route + +from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class DescopeProvider(RemoteAuthProvider): + """Descope metadata provider for DCR (Dynamic Client Registration). + + This provider implements Descope integration using metadata forwarding. + This is the recommended approach for Descope DCR + as it allows Descope to handle the OAuth flow directly while FastMCP acts + as a resource server. + + IMPORTANT SETUP REQUIREMENTS: + + 1. Create an MCP Server in Descope Console: + - Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console + - Create a new MCP Server + - Ensure that **Dynamic Client Registration (DCR)** is enabled + - Note your Well-Known URL + + 2. Note your Well-Known URL: + - Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers) + - Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration`` + + For detailed setup instructions, see: + https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr + + Example: + ```python + from fastmcp.server.plugins.auth.descope.provider import DescopeProvider + + # Create Descope metadata provider (JWT verifier created automatically) + descope_auth = DescopeProvider( + config_url="https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration", + base_url="https://your-fastmcp-server.com", + ) + + # Use with FastMCP + mcp = FastMCP("My App", auth=descope_auth) + ``` + """ + + def __init__( + self, + *, + base_url: AnyHttpUrl | str, + config_url: AnyHttpUrl | str | None = None, + project_id: str | None = None, + descope_base_url: AnyHttpUrl | str | None = None, + required_scopes: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, + token_verifier: TokenVerifier | None = None, + ): + """Initialize Descope metadata provider. + + Args: + base_url: Public URL of this FastMCP server + config_url: Your Descope Well-Known URL (e.g., "https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration") + This is the new recommended way. If provided, project_id and descope_base_url are ignored. + project_id: Your Descope Project ID (e.g., "P2abc123"). Used with descope_base_url for backwards compatibility. + descope_base_url: Your Descope base URL (e.g., "https://api.descope.com"). Used with project_id for backwards compatibility. + required_scopes: Optional list of scopes that must be present in validated tokens. + These scopes will be included in the protected resource metadata. + scopes_supported: Optional list of scopes to advertise in OAuth metadata. + If None, uses required_scopes. Use this when the scopes clients should + request differ from the scopes enforced on tokens. + resource_name: Optional name for the protected resource metadata. + resource_documentation: Optional documentation URL for the protected resource. + token_verifier: Optional token verifier. If None, creates JWT verifier for Descope + """ + self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) + + # Parse scopes if provided as string + parsed_scopes = ( + parse_scopes(required_scopes) if required_scopes is not None else None + ) + + # Determine which API is being used + if config_url is not None: + # New API: use config_url + # Strip /.well-known/openid-configuration from config_url if present + issuer_url = str(config_url) + if issuer_url.endswith("/.well-known/openid-configuration"): + issuer_url = issuer_url[: -len("/.well-known/openid-configuration")] + + # Parse the issuer URL to extract descope_base_url and project_id for other uses + parsed_url = urlparse(issuer_url) + path_parts = parsed_url.path.strip("/").split("/") + + # Extract project_id from path (format: /v1/apps/agentic/P.../M...) + if "agentic" in path_parts: + agentic_index = path_parts.index("agentic") + if agentic_index + 1 < len(path_parts): + self.project_id = path_parts[agentic_index + 1] + else: + raise ValueError( + f"Could not extract project_id from config_url: {issuer_url}" + ) + else: + raise ValueError( + f"Could not find 'agentic' in config_url path: {issuer_url}" + ) + + # Extract descope_base_url (scheme + netloc) + self.descope_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}".rstrip( + "/" + ) + elif project_id is not None and descope_base_url is not None: + # Old API: use project_id and descope_base_url + self.project_id = project_id + descope_base_url_str = str(descope_base_url).rstrip("/") + # Ensure descope_base_url has a scheme + if not descope_base_url_str.startswith(("http://", "https://")): + descope_base_url_str = f"https://{descope_base_url_str}" + self.descope_base_url = descope_base_url_str + # Old issuer format + issuer_url = f"{self.descope_base_url}/v1/apps/{self.project_id}" + else: + raise ValueError( + "Either config_url (new API) or both project_id and descope_base_url (old API) must be provided" + ) + + # Create default JWT verifier if none provided + if token_verifier is None: + token_verifier = JWTVerifier( + jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json", + issuer=issuer_url, + algorithm="RS256", + audience=self.project_id, + required_scopes=parsed_scopes, + ) + + # Initialize RemoteAuthProvider with Descope as the authorization server + super().__init__( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl(issuer_url)], + base_url=self.base_url, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, + ) + + def get_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get OAuth routes including Descope authorization server metadata forwarding. + + This returns the standard protected resource routes plus an authorization server + metadata endpoint that forwards Descope's OAuth metadata to clients. + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + This is used to advertise the resource URL in metadata. + """ + # Get the standard protected resource routes from RemoteAuthProvider + routes = super().get_routes(mcp_path) + + async def oauth_authorization_server_metadata(request): + """Forward Descope OAuth authorization server metadata with FastMCP customizations.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.descope_base_url}/v1/apps/{self.project_id}/.well-known/oauth-authorization-server" + ) + response.raise_for_status() + metadata = response.json() + return JSONResponse(metadata) + except Exception as e: + return JSONResponse( + { + "error": "server_error", + "error_description": f"Failed to fetch Descope metadata: {e}", + }, + status_code=500, + ) + + # Add Descope authorization server metadata forwarding + routes.append( + Route( + "/.well-known/oauth-authorization-server", + endpoint=oauth_authorization_server_metadata, + methods=["GET"], + ) + ) + + return routes diff --git a/src/fastmcp/server/plugins/auth/discord/__init__.py b/src/fastmcp/server/plugins/auth/discord/__init__.py new file mode 100644 index 000000000..4fea3f539 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/discord/__init__.py @@ -0,0 +1,5 @@ +"""Discord auth plugin.""" + +from fastmcp.server.plugins.auth.discord.plugin import DiscordAuth + +__all__ = ["DiscordAuth"] diff --git a/src/fastmcp/server/plugins/auth/discord/plugin.py b/src/fastmcp/server/plugins/auth/discord/plugin.py new file mode 100644 index 000000000..eedeb78fe --- /dev/null +++ b/src/fastmcp/server/plugins/auth/discord/plugin.py @@ -0,0 +1,59 @@ +"""Discord auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import httpx +from key_value.aio.protocols import AsyncKeyValue + +from fastmcp.server.auth import AuthProvider +from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig +from fastmcp.server.plugins.auth.discord.provider import DiscordProvider +from fastmcp.server.plugins.base import PluginMeta + + +class DiscordAuthConfig(OAuthProviderConfig): + """Config model for the Discord auth plugin.""" + + +class DiscordAuth(AuthPlugin[DiscordAuthConfig]): + """Contribute a `DiscordProvider` as the server's auth provider.""" + + Config: ClassVar[type[DiscordAuthConfig]] = DiscordAuthConfig + + meta = PluginMeta(name="discord-auth") + + def __init__( + self, + config: DiscordAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("client_id", "client_secret", "base_url") + return DiscordProvider( + **self._kwargs( + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "issuer_url", + "redirect_path", + "required_scopes", + "timeout_seconds", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) diff --git a/src/fastmcp/server/plugins/auth/discord/provider.py b/src/fastmcp/server/plugins/auth/discord/provider.py new file mode 100644 index 000000000..dc8f72974 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/discord/provider.py @@ -0,0 +1,288 @@ +"""Discord OAuth provider for FastMCP. + +This module provides a complete Discord OAuth integration that's ready to use +with just a client ID and client secret. It handles all the complexity of +Discord's OAuth flow, token validation, and user management. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.discord.provider import DiscordProvider + + # Simple Discord OAuth protection + auth = DiscordProvider( + client_id="your-discord-client-id", + client_secret="your-discord-client-secret" + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` +""" + +from __future__ import annotations + +import contextlib +import time +from datetime import datetime +from typing import Literal + +import httpx +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import TokenVerifier +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class DiscordTokenVerifier(TokenVerifier): + """Token verifier for Discord OAuth tokens. + + Discord OAuth tokens are opaque (not JWTs), so we verify them + by calling Discord's tokeninfo API to check if they're valid and get user info. + """ + + def __init__( + self, + *, + expected_client_id: str, + required_scopes: list[str] | None = None, + timeout_seconds: int = 10, + http_client: httpx.AsyncClient | None = None, + ): + """Initialize the Discord token verifier. + + Args: + expected_client_id: Expected Discord OAuth client ID for audience binding + required_scopes: Required OAuth scopes (e.g., ['email']) + timeout_seconds: HTTP request timeout + http_client: Optional httpx.AsyncClient for connection pooling. When provided, + the client is reused across calls and the caller is responsible for its + lifecycle. When None (default), a fresh client is created per call. + """ + super().__init__(required_scopes=required_scopes) + self.expected_client_id = expected_client_id + self.timeout_seconds = timeout_seconds + self._http_client = http_client + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify Discord OAuth token by calling Discord's tokeninfo API.""" + try: + async with ( + contextlib.nullcontext(self._http_client) + if self._http_client is not None + else httpx.AsyncClient(timeout=self.timeout_seconds) + ) as client: + # Use Discord's tokeninfo endpoint to validate the token + headers = { + "Authorization": f"Bearer {token}", + "User-Agent": "FastMCP-Discord-OAuth", + } + response = await client.get( + "https://discord.com/api/oauth2/@me", + headers=headers, + ) + + if response.status_code != 200: + logger.debug( + "Discord token verification failed: %d", + response.status_code, + ) + return None + + token_info = response.json() + + # Check if token is expired (Discord returns ISO timestamp) + expires_str = token_info.get("expires") + expires_at = None + if expires_str: + expires_dt = datetime.fromisoformat( + expires_str.replace("Z", "+00:00") + ) + expires_at = int(expires_dt.timestamp()) + if expires_at <= int(time.time()): + logger.debug("Discord token has expired") + return None + + token_scopes = token_info.get("scopes", []) + + # Check required scopes + if self.required_scopes: + token_scopes_set = set(token_scopes) + required_scopes_set = set(self.required_scopes) + if not required_scopes_set.issubset(token_scopes_set): + logger.debug( + "Discord token missing required scopes. Has %d, needs %d", + len(token_scopes_set), + len(required_scopes_set), + ) + return None + + user_data = token_info.get("user", {}) + application = token_info.get("application") or {} + client_id = str(application.get("id", "unknown")) + if client_id != self.expected_client_id: + logger.debug( + "Discord token app ID mismatch: expected %s, got %s", + self.expected_client_id, + client_id, + ) + return None + + # Create AccessToken with Discord user info + access_token = AccessToken( + token=token, + client_id=client_id, + scopes=token_scopes, + expires_at=expires_at, + claims={ + "sub": user_data.get("id"), + "username": user_data.get("username"), + "discriminator": user_data.get("discriminator"), + "avatar": user_data.get("avatar"), + "email": user_data.get("email"), + "verified": user_data.get("verified"), + "locale": user_data.get("locale"), + "discord_user": user_data, + "discord_token_info": token_info, + }, + ) + logger.debug("Discord token verified successfully") + return access_token + + except httpx.RequestError as e: + logger.debug("Failed to verify Discord token: %s", e) + return None + except Exception as e: + logger.debug("Discord token verification error: %s", e) + return None + + +class DiscordProvider(OAuthProxy): + """Complete Discord OAuth provider for FastMCP. + + This provider makes it trivial to add Discord OAuth protection to any + FastMCP server. Just provide your Discord OAuth app credentials and + a base URL, and you're ready to go. + + Features: + - Transparent OAuth proxy to Discord + - Automatic token validation via Discord's API + - User information extraction from Discord APIs + - Minimal configuration required + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.discord.provider import DiscordProvider + + auth = DiscordProvider( + client_id="123456789", + client_secret="discord-client-secret-abc123...", + base_url="https://my-server.com" + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + client_id: str, + client_secret: str, + base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, + issuer_url: AnyHttpUrl | str | None = None, + redirect_path: str | None = None, + required_scopes: list[str] | None = None, + timeout_seconds: int = 10, + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | None = None, + require_authorization_consent: bool | Literal["remember", "external"] = True, + consent_csp_policy: str | None = None, + forward_resource: bool = True, + http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, + ): + """Initialize Discord OAuth provider. + + Args: + client_id: Discord OAuth client ID (e.g., "123456789") + client_secret: Discord OAuth client secret (e.g., "S....") + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL + to avoid 404s during discovery when mounting under a path. + redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback") + required_scopes: Required Discord scopes (defaults to ["identify"]). Common scopes include: + - "identify" for profile info (default) + - "email" for email access + - "guilds" for server membership info + timeout_seconds: HTTP request timeout for Discord API calls (defaults to 10) + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). + jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, + they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not + provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. + require_authorization_consent: Whether to require user consent before authorizing clients (default True). + When True, users see a consent screen before being redirected to Discord. + When False, authorization proceeds directly without user confirmation. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. + http_client: Optional httpx.AsyncClient for connection pooling in token verification. + When provided, the client is reused across verify_token calls and the caller + is responsible for its lifecycle. When None (default), a fresh client is created per call. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. + """ + # Parse scopes if provided as string + required_scopes_final = ( + parse_scopes(required_scopes) + if required_scopes is not None + else ["identify"] + ) + + # Create Discord token verifier + token_verifier = DiscordTokenVerifier( + expected_client_id=client_id, + required_scopes=required_scopes_final, + timeout_seconds=timeout_seconds, + http_client=http_client, + ) + + # Initialize OAuth proxy with Discord endpoints + super().__init__( + upstream_authorization_endpoint="https://discord.com/oauth2/authorize", + upstream_token_endpoint="https://discord.com/api/oauth2/token", + upstream_client_id=client_id, + upstream_client_secret=client_secret, + token_verifier=token_verifier, + base_url=base_url, + resource_base_url=resource_base_url, + redirect_path=redirect_path, + issuer_url=issuer_url or base_url, # Default to base_url if not specified + allowed_client_redirect_uris=allowed_client_redirect_uris, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + enable_cimd=enable_cimd, + ) + + logger.debug( + "Initialized Discord OAuth provider for client %s with scopes: %s", + client_id, + required_scopes_final, + ) diff --git a/src/fastmcp/server/plugins/auth/github/__init__.py b/src/fastmcp/server/plugins/auth/github/__init__.py new file mode 100644 index 000000000..5bc2c1159 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/github/__init__.py @@ -0,0 +1,5 @@ +"""GitHub auth plugin.""" + +from fastmcp.server.plugins.auth.github.plugin import GitHubAuth + +__all__ = ["GitHubAuth"] diff --git a/src/fastmcp/server/plugins/auth/github/plugin.py b/src/fastmcp/server/plugins/auth/github/plugin.py new file mode 100644 index 000000000..e11f4f4e4 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/github/plugin.py @@ -0,0 +1,64 @@ +"""GitHub auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import httpx +from key_value.aio.protocols import AsyncKeyValue + +from fastmcp.server.auth import AuthProvider +from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig +from fastmcp.server.plugins.auth.github.provider import GitHubProvider +from fastmcp.server.plugins.base import PluginMeta + + +class GitHubAuthConfig(OAuthProviderConfig): + """Config model for the GitHub auth plugin.""" + + cache_ttl_seconds: int | None = None + max_cache_size: int | None = None + + +class GitHubAuth(AuthPlugin[GitHubAuthConfig]): + """Contribute a `GitHubProvider` as the server's auth provider.""" + + Config: ClassVar[type[GitHubAuthConfig]] = GitHubAuthConfig + + meta = PluginMeta(name="github-auth") + + def __init__( + self, + config: GitHubAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("client_id", "client_secret", "base_url") + return GitHubProvider( + **self._kwargs( + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "issuer_url", + "redirect_path", + "required_scopes", + "timeout_seconds", + "cache_ttl_seconds", + "max_cache_size", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) diff --git a/src/fastmcp/server/plugins/auth/github/provider.py b/src/fastmcp/server/plugins/auth/github/provider.py new file mode 100644 index 000000000..2197acf72 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/github/provider.py @@ -0,0 +1,303 @@ +"""GitHub OAuth provider for FastMCP. + +This module provides a complete GitHub OAuth integration that's ready to use +with just a client ID and client secret. It handles all the complexity of +GitHub's OAuth flow, token validation, and user management. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.github.provider import GitHubProvider + + # Simple GitHub OAuth protection + auth = GitHubProvider( + client_id="your-github-client-id", + client_secret="your-github-client-secret" + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` +""" + +from __future__ import annotations + +import contextlib +from typing import Literal + +import httpx +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import TokenVerifier +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.token_cache import TokenCache + +logger = get_logger(__name__) + + +class GitHubTokenVerifier(TokenVerifier): + """Token verifier for GitHub OAuth tokens. + + GitHub OAuth tokens are opaque (not JWTs), so we verify them + by calling GitHub's API to check if they're valid and get user info. + + Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive + integer to cache successful verification results and avoid repeated + GitHub API calls for the same token. + """ + + def __init__( + self, + *, + required_scopes: list[str] | None = None, + timeout_seconds: int = 10, + cache_ttl_seconds: int | None = None, + max_cache_size: int | None = None, + http_client: httpx.AsyncClient | None = None, + ): + """Initialize the GitHub token verifier. + + Args: + required_scopes: Required OAuth scopes (e.g., ['user:email']) + timeout_seconds: HTTP request timeout + cache_ttl_seconds: How long to cache verification results in seconds. + Caching is disabled by default (None). Set to a positive integer + to enable (e.g., 300 for 5 minutes). + max_cache_size: Maximum number of tokens to cache. Default: 10 000. + http_client: Optional httpx.AsyncClient for connection pooling. When provided, + the client is reused across calls and the caller is responsible for its + lifecycle. When None (default), a fresh client is created per call. + """ + super().__init__(required_scopes=required_scopes) + self.timeout_seconds = timeout_seconds + self._http_client = http_client + self._cache = TokenCache( + ttl_seconds=cache_ttl_seconds, + max_size=max_cache_size, + ) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify GitHub OAuth token by calling GitHub API.""" + is_cached, cached_result = self._cache.get(token) + if is_cached: + logger.debug("GitHub token cache hit") + return cached_result + + try: + async with ( + contextlib.nullcontext(self._http_client) + if self._http_client is not None + else httpx.AsyncClient(timeout=self.timeout_seconds) + ) as client: + # Get token info from GitHub API + response = await client.get( + "https://api.github.com/user", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "FastMCP-GitHub-OAuth", + }, + ) + + if response.status_code != 200: + logger.debug( + "GitHub token verification failed: %d - %s", + response.status_code, + response.text[:200], + ) + return None + + user_data = response.json() + + # Get token scopes from GitHub API + # GitHub includes scopes in the X-OAuth-Scopes header + scopes_response = await client.get( + "https://api.github.com/user/repos", # Any authenticated endpoint + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "User-Agent": "FastMCP-GitHub-OAuth", + }, + ) + + # Extract scopes from X-OAuth-Scopes header if available + scopes_verified = scopes_response.status_code == 200 + oauth_scopes_header = scopes_response.headers.get("x-oauth-scopes", "") + token_scopes = [ + scope.strip() + for scope in oauth_scopes_header.split(",") + if scope.strip() + ] + + # If no scopes in header, assume basic scopes based on successful user API call + if not token_scopes: + token_scopes = ["user"] # Basic scope if we can access user info + + # Check required scopes + if self.required_scopes: + token_scopes_set = set(token_scopes) + required_scopes_set = set(self.required_scopes) + if not required_scopes_set.issubset(token_scopes_set): + logger.debug( + "GitHub token missing required scopes. Has %d, needs %d", + len(token_scopes_set), + len(required_scopes_set), + ) + return None + + # Create AccessToken with GitHub user info + result = AccessToken( + token=token, + client_id=str(user_data.get("id", "unknown")), # Use GitHub user ID + scopes=token_scopes, + expires_at=None, # GitHub tokens don't typically expire + claims={ + "sub": str(user_data["id"]), + "login": user_data.get("login"), + "name": user_data.get("name"), + "email": user_data.get("email"), + "avatar_url": user_data.get("avatar_url"), + "github_user_data": user_data, + }, + ) + if scopes_verified: + self._cache.set(token, result) + return result + + except httpx.RequestError as e: + logger.debug("Failed to verify GitHub token: %s", e) + return None + except Exception as e: + logger.debug("GitHub token verification error: %s", e) + return None + + +class GitHubProvider(OAuthProxy): + """Complete GitHub OAuth provider for FastMCP. + + This provider makes it trivial to add GitHub OAuth protection to any + FastMCP server. Just provide your GitHub OAuth app credentials and + a base URL, and you're ready to go. + + Features: + - Transparent OAuth proxy to GitHub + - Automatic token validation via GitHub API + - User information extraction + - Minimal configuration required + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.github.provider import GitHubProvider + + auth = GitHubProvider( + client_id="Ov23li...", + client_secret="abc123...", + base_url="https://my-server.com" + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + client_id: str, + client_secret: str, + base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, + issuer_url: AnyHttpUrl | str | None = None, + redirect_path: str | None = None, + required_scopes: list[str] | None = None, + timeout_seconds: int = 10, + cache_ttl_seconds: int | None = None, + max_cache_size: int | None = None, + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | None = None, + require_authorization_consent: bool | Literal["remember", "external"] = True, + consent_csp_policy: str | None = None, + forward_resource: bool = True, + http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, + ): + """Initialize GitHub OAuth provider. + + Args: + client_id: GitHub OAuth app client ID (e.g., "Ov23li...") + client_secret: GitHub OAuth app client secret + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL + to avoid 404s during discovery when mounting under a path. + redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback") + required_scopes: Required GitHub scopes (defaults to ["user"]) + timeout_seconds: HTTP request timeout for GitHub API calls (defaults to 10) + cache_ttl_seconds: How long to cache token verification results in seconds. + Caching is disabled by default (None). Set to a positive integer to + enable (e.g., 300 for 5 minutes). + max_cache_size: Maximum number of tokens to cache. Default: 10 000. + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). + jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, + they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not + provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. + require_authorization_consent: Whether to require user consent before authorizing clients (default True). + When True, users see a consent screen before being redirected to GitHub. + When False, authorization proceeds directly without user confirmation. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. + http_client: Optional httpx.AsyncClient for connection pooling in token verification. + When provided, the client is reused across verify_token calls and the caller + is responsible for its lifecycle. When None (default), a fresh client is created per call. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. + """ + # Parse scopes if provided as string + required_scopes_final = ( + parse_scopes(required_scopes) if required_scopes is not None else ["user"] + ) + + # Create GitHub token verifier + token_verifier = GitHubTokenVerifier( + required_scopes=required_scopes_final, + timeout_seconds=timeout_seconds, + cache_ttl_seconds=cache_ttl_seconds, + max_cache_size=max_cache_size, + http_client=http_client, + ) + + # Initialize OAuth proxy with GitHub endpoints + super().__init__( + upstream_authorization_endpoint="https://github.com/login/oauth/authorize", + upstream_token_endpoint="https://github.com/login/oauth/access_token", + upstream_client_id=client_id, + upstream_client_secret=client_secret, + token_verifier=token_verifier, + base_url=base_url, + resource_base_url=resource_base_url, + redirect_path=redirect_path, + issuer_url=issuer_url or base_url, # Default to base_url if not specified + allowed_client_redirect_uris=allowed_client_redirect_uris, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + enable_cimd=enable_cimd, + ) + + logger.debug( + "Initialized GitHub OAuth provider for client %s with scopes: %s", + client_id, + required_scopes_final, + ) diff --git a/src/fastmcp/server/plugins/auth/google/__init__.py b/src/fastmcp/server/plugins/auth/google/__init__.py new file mode 100644 index 000000000..e2b72a12e --- /dev/null +++ b/src/fastmcp/server/plugins/auth/google/__init__.py @@ -0,0 +1,5 @@ +"""Google auth plugin.""" + +from fastmcp.server.plugins.auth.google.plugin import GoogleAuth + +__all__ = ["GoogleAuth"] diff --git a/src/fastmcp/server/plugins/auth/google/plugin.py b/src/fastmcp/server/plugins/auth/google/plugin.py new file mode 100644 index 000000000..381b82ac7 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/google/plugin.py @@ -0,0 +1,65 @@ +"""Google auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import httpx +from key_value.aio.protocols import AsyncKeyValue + +from fastmcp.server.auth import AuthProvider +from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig +from fastmcp.server.plugins.auth.google.provider import GoogleProvider +from fastmcp.server.plugins.base import PluginMeta + + +class GoogleAuthConfig(OAuthProviderConfig): + """Config model for the Google auth plugin.""" + + valid_scopes: list[str] | None = None + extra_authorize_params: dict[str, str] | None = None + + +class GoogleAuth(AuthPlugin[GoogleAuthConfig]): + """Contribute a `GoogleProvider` as the server's auth provider.""" + + Config: ClassVar[type[GoogleAuthConfig]] = GoogleAuthConfig + + meta = PluginMeta(name="google-auth") + + def __init__( + self, + config: GoogleAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("client_id", "base_url") + self._require_one("client_secret", "jwt_signing_key") + return GoogleProvider( + **self._kwargs( + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "issuer_url", + "redirect_path", + "required_scopes", + "valid_scopes", + "timeout_seconds", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "extra_authorize_params", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) diff --git a/src/fastmcp/server/plugins/auth/google/provider.py b/src/fastmcp/server/plugins/auth/google/provider.py new file mode 100644 index 000000000..3f165397c --- /dev/null +++ b/src/fastmcp/server/plugins/auth/google/provider.py @@ -0,0 +1,365 @@ +"""Google OAuth provider for FastMCP. + +This module provides a complete Google OAuth integration that's ready to use +with just a client ID and client secret. It handles all the complexity of +Google's OAuth flow, token validation, and user management. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.google.provider import GoogleProvider + + # Simple Google OAuth protection + auth = GoogleProvider( + client_id="your-google-client-id.apps.googleusercontent.com", + client_secret="your-google-client-secret" + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` +""" + +from __future__ import annotations + +import contextlib +import time +from typing import Literal + +import httpx +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import TokenVerifier +from fastmcp.server.auth.auth import AccessToken +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +GOOGLE_SCOPE_ALIASES: dict[str, str] = { + "email": "https://www.googleapis.com/auth/userinfo.email", + "profile": "https://www.googleapis.com/auth/userinfo.profile", +} + + +def _normalize_google_scope(scope: str) -> str: + """Normalize a Google scope shorthand to its canonical full URI. + + Google accepts shorthand scopes like "email" and "profile" in authorization + requests, but returns the full URI form in token responses. This normalizes + to the full URI so comparisons work regardless of which form was used. + """ + return GOOGLE_SCOPE_ALIASES.get(scope, scope) + + +class GoogleTokenVerifier(TokenVerifier): + """Token verifier for Google OAuth tokens. + + Google OAuth tokens are opaque (not JWTs), so we verify them by calling + Google's tokeninfo endpoint with the access token as a query parameter. + This returns the OAuth app ID (``aud``), granted scopes, and expiry time. + User profile data (name, picture, etc.) is fetched separately from the + v2 userinfo endpoint when the token is valid. + """ + + def __init__( + self, + *, + required_scopes: list[str] | None = None, + timeout_seconds: int = 10, + http_client: httpx.AsyncClient | None = None, + ): + """Initialize the Google token verifier. + + Args: + required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email']) + timeout_seconds: HTTP request timeout + http_client: Optional httpx.AsyncClient for connection pooling. When provided, + the client is reused across calls and the caller is responsible for its + lifecycle. When None (default), a fresh client is created per call. + """ + normalized = ( + [_normalize_google_scope(s) for s in required_scopes] + if required_scopes + else required_scopes + ) + super().__init__(required_scopes=normalized) + self.timeout_seconds = timeout_seconds + self._http_client = http_client + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify a Google OAuth token using the tokeninfo endpoint. + + Calls ``https://oauth2.googleapis.com/tokeninfo?access_token=TOKEN`` + to validate the token and retrieve the OAuth app ID (``aud``), granted + scopes, and expiry time. On success, fetches user profile data from + the v2 userinfo endpoint to populate name, picture, and locale claims. + """ + try: + async with ( + contextlib.nullcontext(self._http_client) + if self._http_client is not None + else httpx.AsyncClient(timeout=self.timeout_seconds) + ) as client: + # Step 1: Verify token via tokeninfo endpoint. + # Returns aud (OAuth app ID), scope (space-separated), expires_in, sub, email. + response = await client.get( + "https://oauth2.googleapis.com/tokeninfo", + params={"access_token": token}, + headers={"User-Agent": "FastMCP-Google-OAuth"}, + ) + + if response.status_code != 200: + logger.debug( + "Google token verification failed: %d", + response.status_code, + ) + return None + + token_data = response.json() + + # aud is the OAuth app ID (client_id / audience) + aud = token_data.get("aud") + if not aud: + logger.debug("Google tokeninfo missing 'aud' claim") + return None + + # sub is required (unique Google user ID) + sub = token_data.get("sub") + if not sub: + logger.debug("Google tokeninfo missing 'sub' claim") + return None + + # Parse scopes directly from the tokeninfo response (space-separated) + scope_str = token_data.get("scope", "") + token_scopes = scope_str.split() if scope_str else [] + + # Check required scopes + if self.required_scopes: + token_scopes_set = set(token_scopes) + required_scopes_set = set(self.required_scopes) + if not required_scopes_set.issubset(token_scopes_set): + logger.debug( + "Google token missing required scopes. Has %d, needs %d", + len(token_scopes_set), + len(required_scopes_set), + ) + return None + + # Compute expiry from expires_in (seconds until expiry) + expires_at: int | None = None + expires_in = token_data.get("expires_in") + if expires_in is not None: + with contextlib.suppress(ValueError, TypeError): + expires_at = int(time.time()) + int(expires_in) + + # Step 2: Fetch user profile from v2 userinfo endpoint. + # tokeninfo provides auth data; userinfo provides name, picture, locale. + user_data: dict = {} + try: + userinfo_response = await client.get( + "https://www.googleapis.com/oauth2/v2/userinfo", + headers={ + "Authorization": f"Bearer {token}", + "User-Agent": "FastMCP-Google-OAuth", + }, + ) + if userinfo_response.status_code == 200: + user_data = userinfo_response.json() + except Exception as e: + logger.debug("Failed to fetch Google user profile: %s", e) + + access_token = AccessToken( + token=token, + client_id=sub, + scopes=token_scopes, + expires_at=expires_at, + claims={ + "sub": sub, + "aud": aud, + "email": token_data.get("email") or user_data.get("email"), + "email_verified": token_data.get("email_verified") + or user_data.get("verified_email"), + "name": user_data.get("name"), + "picture": user_data.get("picture"), + "given_name": user_data.get("given_name"), + "family_name": user_data.get("family_name"), + "locale": user_data.get("locale"), + "google_user_data": user_data or None, + }, + ) + logger.debug("Google token verified successfully") + return access_token + + except httpx.RequestError as e: + logger.debug("Failed to verify Google token: %s", e) + return None + except Exception as e: + logger.debug("Google token verification error: %s", e) + return None + + +class GoogleProvider(OAuthProxy): + """Complete Google OAuth provider for FastMCP. + + This provider makes it trivial to add Google OAuth protection to any + FastMCP server. Just provide your Google OAuth app credentials and + a base URL, and you're ready to go. + + Features: + - Transparent OAuth proxy to Google + - Automatic token validation via Google's tokeninfo API + - User information extraction from Google APIs + - Minimal configuration required + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.google.provider import GoogleProvider + + auth = GoogleProvider( + client_id="123456789.apps.googleusercontent.com", + client_secret="GOCSPX-abc123...", + base_url="https://my-server.com" + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + client_id: str, + client_secret: str | None = None, + base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, + issuer_url: AnyHttpUrl | str | None = None, + redirect_path: str | None = None, + required_scopes: list[str] | None = None, + valid_scopes: list[str] | None = None, + timeout_seconds: int = 10, + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | None = None, + require_authorization_consent: bool | Literal["remember", "external"] = True, + consent_csp_policy: str | None = None, + forward_resource: bool = True, + extra_authorize_params: dict[str, str] | None = None, + http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, + ): + """Initialize Google OAuth provider. + + Args: + client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com") + client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123..."). + Optional for PKCE public clients (e.g., native apps). When omitted, + jwt_signing_key must be provided. + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL + to avoid 404s during discovery when mounting under a path. + redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback") + required_scopes: Required Google scopes (defaults to ["openid"]). Common scopes include: + - "openid" for OpenID Connect (default) + - "https://www.googleapis.com/auth/userinfo.email" for email access + - "https://www.googleapis.com/auth/userinfo.profile" for profile info + Google scope shorthands like "email" and "profile" are automatically + normalized to their full URI forms for token verification. + valid_scopes: All scopes that clients are allowed to request, advertised through + well-known endpoints. Defaults to required_scopes if not provided. Use this + when you want clients to be able to request additional scopes beyond the + required minimum. Shorthands are normalized to full URI forms. + timeout_seconds: HTTP request timeout for Google API calls (defaults to 10) + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). + jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, + they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not + provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. + require_authorization_consent: Whether to require user consent before authorizing clients (default True). + When True, users see a consent screen before being redirected to Google. + When False, authorization proceeds directly without user confirmation. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by Google's own consent). + SECURITY WARNING: Only set to False for local development or testing environments. + extra_authorize_params: Additional parameters to forward to Google's authorization endpoint. + By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure + refresh tokens are returned. You can override these defaults or add additional parameters. + Example: {"prompt": "select_account"} to let users choose their Google account. + http_client: Optional httpx.AsyncClient for connection pooling in token verification. + When provided, the client is reused across verify_token calls and the caller + is responsible for its lifecycle. When None (default), a fresh client is created per call. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. + """ + # Parse scopes if provided as string + # Google requires at least one scope - openid is the minimal OIDC scope + required_scopes_final = ( + parse_scopes(required_scopes) if required_scopes is not None else ["openid"] + ) + + # Normalize valid_scopes if provided + parsed_valid_scopes = ( + parse_scopes(valid_scopes) if valid_scopes is not None else None + ) + valid_scopes_final = ( + [_normalize_google_scope(s) for s in parsed_valid_scopes] + if parsed_valid_scopes is not None + else None + ) + + # Create Google token verifier + # Normalization of shorthand scopes (e.g. "email" -> full URI) happens + # inside GoogleTokenVerifier so required_scopes match what Google returns. + token_verifier = GoogleTokenVerifier( + required_scopes=required_scopes_final, + timeout_seconds=timeout_seconds, + http_client=http_client, + ) + + # Set Google-specific defaults for extra authorize params + # access_type=offline ensures refresh tokens are returned + # prompt=consent forces consent screen to get refresh token (Google only issues on first auth otherwise) + google_defaults = { + "access_type": "offline", + "prompt": "consent", + } + # User-provided params override defaults + if extra_authorize_params: + google_defaults.update(extra_authorize_params) + extra_authorize_params_final = google_defaults + + # Initialize OAuth proxy with Google endpoints + super().__init__( + upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth", + upstream_token_endpoint="https://oauth2.googleapis.com/token", + upstream_client_id=client_id, + upstream_client_secret=client_secret, + token_verifier=token_verifier, + base_url=base_url, + resource_base_url=resource_base_url, + redirect_path=redirect_path, + issuer_url=issuer_url or base_url, # Default to base_url if not specified + allowed_client_redirect_uris=allowed_client_redirect_uris, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + extra_authorize_params=extra_authorize_params_final, + valid_scopes=valid_scopes_final, + enable_cimd=enable_cimd, + ) + + logger.debug( + "Initialized Google OAuth provider for client %s with scopes: %s", + client_id, + required_scopes_final, + ) diff --git a/src/fastmcp/server/plugins/auth/keycloak/__init__.py b/src/fastmcp/server/plugins/auth/keycloak/__init__.py new file mode 100644 index 000000000..e5a5c1593 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/keycloak/__init__.py @@ -0,0 +1,5 @@ +"""Keycloak auth plugin.""" + +from fastmcp.server.plugins.auth.keycloak.plugin import KeycloakAuth + +__all__ = ["KeycloakAuth"] diff --git a/src/fastmcp/server/plugins/auth/keycloak/plugin.py b/src/fastmcp/server/plugins/auth/keycloak/plugin.py new file mode 100644 index 000000000..1399fe082 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/keycloak/plugin.py @@ -0,0 +1,45 @@ +"""Keycloak auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import AuthProvider, TokenVerifier +from fastmcp.server.plugins.auth._base import AuthPlugin, PluginConfig +from fastmcp.server.plugins.auth.keycloak.provider import KeycloakAuthProvider +from fastmcp.server.plugins.base import PluginMeta + + +class KeycloakAuthConfig(PluginConfig): + """Config model for the Keycloak auth plugin.""" + + realm_url: AnyHttpUrl | str | None = None + base_url: AnyHttpUrl | str | None = None + required_scopes: list[str] | str | None = None + audience: str | list[str] | None = None + + +class KeycloakAuth(AuthPlugin[KeycloakAuthConfig]): + """Contribute a `KeycloakAuthProvider` as the server's auth provider.""" + + Config: ClassVar[type[KeycloakAuthConfig]] = KeycloakAuthConfig + + meta = PluginMeta(name="keycloak-auth") + + def __init__( + self, + config: KeycloakAuthConfig | dict[str, Any] | None = None, + *, + token_verifier: TokenVerifier | None = None, + ) -> None: + super().__init__(config) + self._token_verifier = token_verifier + + def auth(self) -> AuthProvider | None: + self._require("realm_url", "base_url") + return KeycloakAuthProvider( + **self._kwargs("realm_url", "base_url", "required_scopes", "audience"), + token_verifier=self._token_verifier, + ) diff --git a/src/fastmcp/server/plugins/auth/keycloak/provider.py b/src/fastmcp/server/plugins/auth/keycloak/provider.py new file mode 100644 index 000000000..b4c2eead2 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/keycloak/provider.py @@ -0,0 +1,74 @@ +"""Keycloak authentication provider for FastMCP.""" + +from __future__ import annotations + +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class KeycloakAuthProvider(RemoteAuthProvider): + """Keycloak authentication provider using Dynamic Client Registration (DCR). + + Requires Keycloak 26.6.0 or later, which includes the fix for DCR compatibility + with MCP clients (https://github.com/keycloak/keycloak/pull/45309). + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.keycloak.provider import KeycloakAuthProvider + + auth = KeycloakAuthProvider( + realm_url="https://keycloak.example.com/realms/myrealm", + base_url="https://my-mcp-server.example.com", + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + realm_url: AnyHttpUrl | str, + base_url: AnyHttpUrl | str, + required_scopes: list[str] | str | None = None, + audience: str | list[str] | None = None, + token_verifier: TokenVerifier | None = None, + ): + """Initialize the Keycloak auth provider. + + Args: + realm_url: Keycloak realm URL (e.g., "https://keycloak.example.com/realms/myrealm") + base_url: Public URL of this FastMCP server + required_scopes: Scopes to require on incoming tokens. Defaults to + ["openid"], which ensures the `sub` claim (user identifier) is + present in the access token. Override to require additional scopes. + audience: Optional audience(s) for JWT validation. Recommended for production. + token_verifier: Optional custom token verifier. Defaults to a JWTVerifier + configured for Keycloak's JWKS endpoint and issuer. + """ + self.realm_url = str(realm_url).rstrip("/") + parsed_scopes = ( + parse_scopes(required_scopes) if required_scopes is not None else ["openid"] + ) + + if token_verifier is None: + token_verifier = JWTVerifier( + jwks_uri=f"{self.realm_url}/protocol/openid-connect/certs", + issuer=self.realm_url, + algorithm="RS256", + required_scopes=parsed_scopes, + audience=audience, + ) + + super().__init__( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl(self.realm_url)], + base_url=AnyHttpUrl(str(base_url).rstrip("/")), + ) diff --git a/src/fastmcp/server/plugins/auth/oci/__init__.py b/src/fastmcp/server/plugins/auth/oci/__init__.py new file mode 100644 index 000000000..db49aadf3 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/oci/__init__.py @@ -0,0 +1,5 @@ +"""OCI auth plugin.""" + +from fastmcp.server.plugins.auth.oci.plugin import OCIAuth + +__all__ = ["OCIAuth"] diff --git a/src/fastmcp/server/plugins/auth/oci/plugin.py b/src/fastmcp/server/plugins/auth/oci/plugin.py new file mode 100644 index 000000000..e319e4aac --- /dev/null +++ b/src/fastmcp/server/plugins/auth/oci/plugin.py @@ -0,0 +1,61 @@ +"""OCI auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import AuthProvider +from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProxyConfig +from fastmcp.server.plugins.auth.oci.provider import OCIProvider +from fastmcp.server.plugins.base import PluginMeta + + +class OCIAuthConfig(OAuthProxyConfig): + """Config model for the OCI auth plugin.""" + + config_url: AnyHttpUrl | str | None = None + client_id: str | None = None + client_secret: str | None = None + audience: str | None = None + + +class OCIAuth(AuthPlugin[OCIAuthConfig]): + """Contribute an `OCIProvider` as the server's auth provider.""" + + Config: ClassVar[type[OCIAuthConfig]] = OCIAuthConfig + + meta = PluginMeta(name="oci-auth") + + def __init__( + self, + config: OCIAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + + def auth(self) -> AuthProvider | None: + self._require("config_url", "client_id", "client_secret", "base_url") + return OCIProvider( + **self._kwargs( + "config_url", + "client_id", + "client_secret", + "base_url", + "resource_base_url", + "audience", + "issuer_url", + "required_scopes", + "redirect_path", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + ), + client_storage=self._client_storage, + ) diff --git a/src/fastmcp/server/plugins/auth/oci/provider.py b/src/fastmcp/server/plugins/auth/oci/provider.py new file mode 100644 index 000000000..fe3841ad6 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/oci/provider.py @@ -0,0 +1,180 @@ +"""OCI OIDC provider for FastMCP. + +The pull request for the provider is submitted to fastmcp. + +This module provides OIDC Implementation to integrate MCP servers with OCI. +You only need OCI Identity Domain's discovery URL, client ID, client secret, and base URL. + +Post Authentication, you get OCI IAM domain access token. That is not authorized to invoke OCI control plane. +You need to exchange the IAM domain access token for OCI UPST token to invoke OCI control plane APIs. +The sample code below has get_oci_signer function that returns OCI TokenExchangeSigner object. +You can use the signer object to create OCI service object. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.oci.provider import OCIProvider + from fastmcp.server.dependencies import get_access_token + from fastmcp.utilities.logging import get_logger + + import os + + import oci + from oci.auth.signers import TokenExchangeSigner + + logger = get_logger(__name__) + + # Load configuration from environment + config_url = os.environ.get("OCI_CONFIG_URL") # OCI IAM Domain OIDC discovery URL + client_id = os.environ.get("OCI_CLIENT_ID") # Client ID configured for the OCI IAM Domain Integrated Application + client_secret = os.environ.get("OCI_CLIENT_SECRET") # Client secret configured for the OCI IAM Domain Integrated Application + iam_guid = os.environ.get("OCI_IAM_GUID") # IAM GUID configured for the OCI IAM Domain + + # Simple OCI OIDC protection + auth = OCIProvider( + config_url=config_url, # config URL is the OCI IAM Domain OIDC discovery URL + client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application + client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application + required_scopes=["openid", "profile", "email"], + redirect_path="/auth/callback", + base_url="http://localhost:8000", + ) + + # NOTE: For production use, replace this with a thread-safe cache implementation + # such as threading.Lock-protected dict or a proper caching library + _global_token_cache = {} # In memory cache for OCI session token signer + + def get_oci_signer() -> TokenExchangeSigner: + + authntoken = get_access_token() + tokenID = authntoken.claims.get("jti") + token = authntoken.token + + # Check if the signer exists for the token ID in memory cache + cached_signer = _global_token_cache.get(tokenID) + logger.debug(f"Global cached signer: {cached_signer}") + if cached_signer: + logger.debug(f"Using globally cached signer for token ID: {tokenID}") + return cached_signer + + # If the signer is not yet created for the token then create new OCI signer object + logger.debug(f"Creating new signer for token ID: {tokenID}") + signer = TokenExchangeSigner( + jwt_or_func=token, + oci_domain_id=iam_guid.split(".")[0] if iam_guid else None, # This is same as IAM GUID configured for the OCI IAM Domain + client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application + client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application + ) + logger.debug(f"Signer {signer} created for token ID: {tokenID}") + + #Cache the signer object in memory cache + _global_token_cache[tokenID] = signer + logger.debug(f"Signer cached for token ID: {tokenID}") + + return signer + + mcp = FastMCP("My Protected Server", auth=auth) + ``` +""" + +from typing import Literal + +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth.oidc_proxy import OIDCProxy +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class OCIProvider(OIDCProxy): + """An OCI IAM Domain provider implementation for FastMCP. + + This provider is a complete OCI integration that's ready to use with + just the configuration URL, client ID, client secret, and base URL. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.oci.provider import OCIProvider + + import os + + # Load configuration from environment + auth = OCIProvider( + config_url=os.environ.get("OCI_CONFIG_URL"), # OCI IAM Domain OIDC discovery URL + client_id=os.environ.get("OCI_CLIENT_ID"), # Client ID configured for the OCI IAM Domain Integrated Application + client_secret=os.environ.get("OCI_CLIENT_SECRET"), # Client secret configured for the OCI IAM Domain Integrated Application + base_url="http://localhost:8000", + required_scopes=["openid", "profile", "email"], + redirect_path="/auth/callback", + ) + + mcp = FastMCP("My Protected Server", auth=auth) + ``` + """ + + def __init__( + self, + *, + config_url: AnyHttpUrl | str, + client_id: str, + client_secret: str, + base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, + audience: str | None = None, + issuer_url: AnyHttpUrl | str | None = None, + required_scopes: list[str] | None = None, + redirect_path: str | None = None, + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | None = None, + require_authorization_consent: bool | Literal["remember", "external"] = True, + consent_csp_policy: str | None = None, + forward_resource: bool = True, + ) -> None: + """Initialize OCI OIDC provider. + + Args: + config_url: OCI OIDC Discovery URL + client_id: OCI IAM Domain Integrated Application client id + client_secret: OCI Integrated Application client secret + base_url: Public URL where OIDC endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. + audience: OCI API audience (optional) + issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL. + required_scopes: Required OCI scopes (defaults to ["openid"]) + redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback". + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + """ + # Parse scopes if provided as string + oci_required_scopes = ( + parse_scopes(required_scopes) if required_scopes is not None else ["openid"] + ) + + super().__init__( + config_url=config_url, + client_id=client_id, + client_secret=client_secret, + audience=audience, + base_url=base_url, + resource_base_url=resource_base_url, + issuer_url=issuer_url, + redirect_path=redirect_path, + required_scopes=oci_required_scopes, + allowed_client_redirect_uris=allowed_client_redirect_uris, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + ) + + logger.debug( + "Initialized OCI OAuth provider for client %s with scopes: %s", + client_id, + oci_required_scopes, + ) diff --git a/src/fastmcp/server/plugins/auth/propelauth/__init__.py b/src/fastmcp/server/plugins/auth/propelauth/__init__.py new file mode 100644 index 000000000..dc674d55f --- /dev/null +++ b/src/fastmcp/server/plugins/auth/propelauth/__init__.py @@ -0,0 +1,5 @@ +"""PropelAuth auth plugin.""" + +from fastmcp.server.plugins.auth.propelauth.plugin import PropelAuth + +__all__ = ["PropelAuth"] diff --git a/src/fastmcp/server/plugins/auth/propelauth/plugin.py b/src/fastmcp/server/plugins/auth/propelauth/plugin.py new file mode 100644 index 000000000..f58d14da7 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/propelauth/plugin.py @@ -0,0 +1,77 @@ +"""PropelAuth auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import httpx +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import AuthProvider +from fastmcp.server.plugins.auth._base import AuthPlugin, RemoteAuthConfig +from fastmcp.server.plugins.auth.propelauth.provider import ( + PropelAuthProvider, + PropelAuthTokenIntrospectionOverrides, +) +from fastmcp.server.plugins.base import PluginMeta + + +class PropelAuthConfig(RemoteAuthConfig): + """Config model for the PropelAuth auth plugin.""" + + auth_url: AnyHttpUrl | str | None = None + introspection_client_id: str | None = None + introspection_client_secret: str | None = None + resource: AnyHttpUrl | str | None = None + introspection_timeout_seconds: int | None = None + introspection_cache_ttl_seconds: int | None = None + introspection_max_cache_size: int | None = None + + +class PropelAuth(AuthPlugin[PropelAuthConfig]): + """Contribute a `PropelAuthProvider` as the server's auth provider.""" + + Config: ClassVar[type[PropelAuthConfig]] = PropelAuthConfig + + meta = PluginMeta(name="propelauth-auth") + + def __init__( + self, + config: PropelAuthConfig | dict[str, Any] | None = None, + *, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require( + "auth_url", + "introspection_client_id", + "introspection_client_secret", + "base_url", + ) + overrides: PropelAuthTokenIntrospectionOverrides = {} + if self.config.introspection_timeout_seconds is not None: + overrides["timeout_seconds"] = self.config.introspection_timeout_seconds + if self.config.introspection_cache_ttl_seconds is not None: + overrides["cache_ttl_seconds"] = self.config.introspection_cache_ttl_seconds + if self.config.introspection_max_cache_size is not None: + overrides["max_cache_size"] = self.config.introspection_max_cache_size + if self._http_client is not None: + overrides["http_client"] = self._http_client + + return PropelAuthProvider( + **self._kwargs( + "auth_url", + "introspection_client_id", + "introspection_client_secret", + "base_url", + "required_scopes", + "scopes_supported", + "resource_name", + "resource_documentation", + "resource", + ), + token_introspection_overrides=overrides or None, + ) diff --git a/src/fastmcp/server/plugins/auth/propelauth/provider.py b/src/fastmcp/server/plugins/auth/propelauth/provider.py new file mode 100644 index 000000000..b7073b4da --- /dev/null +++ b/src/fastmcp/server/plugins/auth/propelauth/provider.py @@ -0,0 +1,234 @@ +"""PropelAuth authentication provider for FastMCP. + +Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.propelauth.provider import PropelAuthProvider + + auth = PropelAuthProvider( + auth_url="https://auth.yourdomain.com", + introspection_client_id="your-client-id", + introspection_client_secret="your-client-secret", + base_url="https://your-fastmcp-server.com", + required_scopes=["read:user_data"], + ) + + mcp = FastMCP("My App", auth=auth) + ``` +""" + +from __future__ import annotations + +from typing import TypedDict + +import httpx +from pydantic import AnyHttpUrl, SecretStr +from starlette.responses import JSONResponse +from starlette.routing import Route + +from fastmcp.server.auth import AccessToken, RemoteAuthProvider +from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class PropelAuthTokenIntrospectionOverrides(TypedDict, total=False): + timeout_seconds: int + cache_ttl_seconds: int | None + max_cache_size: int | None + http_client: httpx.AsyncClient | None + + +class PropelAuthProvider(RemoteAuthProvider): + """PropelAuth resource server provider using OAuth 2.1 token introspection. + + This provider validates access tokens via PropelAuth's introspection endpoint + and forwards authorization server metadata for OAuth discovery. + + Setup: + 1. Enable MCP authentication in the PropelAuth Dashboard + 2. Configure scopes on the MCP page + 3. Select which redirect URIs to enable by picking which clients you support + 4. Generate introspection credentials (Client ID + Client Secret) + + For detailed setup instructions, see: + https://docs.propelauth.com/mcp-authentication/overview + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.propelauth.provider import PropelAuthProvider + + auth = PropelAuthProvider( + auth_url="https://auth.yourdomain.com", + introspection_client_id="your-client-id", + introspection_client_secret="your-client-secret", + base_url="https://your-fastmcp-server.com", + required_scopes=["read:user_data"], + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + auth_url: AnyHttpUrl | str, + introspection_client_id: str, + introspection_client_secret: str | SecretStr, + base_url: AnyHttpUrl | str, + required_scopes: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, + resource: AnyHttpUrl | str | None = None, + token_introspection_overrides: ( + PropelAuthTokenIntrospectionOverrides | None + ) = None, + ): + """Initialize PropelAuth provider. + + Args: + auth_url: Your PropelAuth Auth URL (from the Backend Integration page) + introspection_client_id: Introspection Client ID from the PropelAuth Dashboard + introspection_client_secret: Introspection Client Secret from the PropelAuth Dashboard + base_url: Public URL of this FastMCP server + required_scopes: Optional list of scopes that must be present in tokens + scopes_supported: Optional list of scopes to advertise in OAuth metadata. + If None, uses required_scopes. Use this when the scopes clients should + request differ from the scopes enforced on tokens. + resource_name: Optional name for the protected resource metadata. + resource_documentation: Optional documentation URL for the protected resource. + resource: Optional resource URI (RFC 8707) identifying this MCP server. + Use this when multiple MCP servers share the same PropelAuth + authorization server (e.g. ``resource="https://api.example.com/mcp"``), + so only tokens intended for this MCP server are accepted. + token_introspection_overrides: Optional overrides for the underlying + IntrospectionTokenVerifier (timeout, caching, http_client) + """ + normalized_auth_url = str(auth_url).rstrip("/") + introspection_url = f"{normalized_auth_url}/oauth/2.1/introspect" + authorization_server_url = AnyHttpUrl(f"{normalized_auth_url}/oauth/2.1") + + if resource is None: + self._resource = None + logger.debug( + "PropelAuthProvider: no resource configured, audience checking disabled" + ) + else: + self._resource = str(resource) + + token_verifier = self._create_token_verifier( + introspection_url=introspection_url, + client_id=introspection_client_id, + client_secret=introspection_client_secret, + required_scopes=required_scopes, + introspection_overrides=token_introspection_overrides, + ) + + self._normalized_auth_url = normalized_auth_url + super().__init__( + token_verifier=token_verifier, + authorization_servers=[authorization_server_url], + base_url=base_url, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, + ) + + def get_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get routes for this provider. + + Includes the standard routes from the RemoteAuthProvider (protected resource metadata routes (RFC 9728)), + and creates an authorization server metadata route that forwards to PropelAuth's route + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + This is used to advertise the resource URL in metadata. + """ + routes = super().get_routes(mcp_path) + + async def oauth_authorization_server_metadata(request): + """Forward PropelAuth OAuth authorization server metadata""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self._normalized_auth_url}/.well-known/oauth-authorization-server/oauth/2.1" + ) + response.raise_for_status() + metadata = response.json() + return JSONResponse(metadata) + except Exception as e: + return JSONResponse( + { + "error": "server_error", + "error_description": f"Failed to fetch PropelAuth metadata: {e}", + }, + status_code=500, + ) + + routes.append( + Route( + "/.well-known/oauth-authorization-server", + endpoint=oauth_authorization_server_metadata, + methods=["GET"], + ) + ) + + return routes + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token and check the ``aud`` claim against the configured resource.""" + result = await super().verify_token(token) + if result is None or self._resource is None: + return result + + aud = result.claims.get("aud") + if aud != self._resource: + logger.debug( + "PropelAuthProvider: token audience %r does not match resource %s", + aud, + self._resource, + ) + return None + + return result + + def _create_token_verifier( + self, + introspection_url: str, + client_id: str, + client_secret: str | SecretStr, + required_scopes: list[str] | None, + introspection_overrides: PropelAuthTokenIntrospectionOverrides | None, + ) -> IntrospectionTokenVerifier: + # Being defensive here, check for only the fields we are expecting + safe_overrides: PropelAuthTokenIntrospectionOverrides = {} + if introspection_overrides is not None: + if "timeout_seconds" in introspection_overrides: + safe_overrides["timeout_seconds"] = introspection_overrides[ + "timeout_seconds" + ] + if "cache_ttl_seconds" in introspection_overrides: + safe_overrides["cache_ttl_seconds"] = introspection_overrides[ + "cache_ttl_seconds" + ] + if "max_cache_size" in introspection_overrides: + safe_overrides["max_cache_size"] = introspection_overrides[ + "max_cache_size" + ] + if "http_client" in introspection_overrides: + safe_overrides["http_client"] = introspection_overrides["http_client"] + + return IntrospectionTokenVerifier( + introspection_url=introspection_url, + client_id=client_id, + client_secret=client_secret, + required_scopes=required_scopes, + **safe_overrides, + ) diff --git a/src/fastmcp/server/plugins/auth/providers.py b/src/fastmcp/server/plugins/auth/providers.py deleted file mode 100644 index d25edcfb4..000000000 --- a/src/fastmcp/server/plugins/auth/providers.py +++ /dev/null @@ -1,792 +0,0 @@ -"""First-party auth plugins. - -These plugins are thin, JSON-configurable wrappers around FastMCP's -existing auth providers. Python-only dependencies such as HTTP clients, -token verifiers, and client storage stay as constructor arguments. -""" - -from __future__ import annotations - -from typing import Any, Generic, Literal, TypeVar - -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl, BaseModel, ConfigDict - -from fastmcp.server.auth import AuthProvider, TokenVerifier -from fastmcp.server.plugins.base import Plugin, PluginMeta - -ConsentMode = bool | Literal["remember", "external"] -Algorithm = Literal["RS256", "ES256"] -ConfigT = TypeVar("ConfigT", bound=BaseModel) - - -class _AuthPlugin(Plugin[ConfigT], Generic[ConfigT]): - def _require(self, *fields: str) -> None: - missing = [field for field in fields if getattr(self.config, field) is None] - if missing: - names = ", ".join(f"`{field}`" for field in missing) - raise ValueError(f"{type(self).__name__} requires {names}.") - - def _require_one(self, *fields: str) -> None: - if not any(getattr(self.config, field) is not None for field in fields): - names = " or ".join(f"`{field}`" for field in fields) - raise ValueError(f"{type(self).__name__} requires {names}.") - - def _kwargs(self, *fields: str) -> dict[str, Any]: - return { - field: getattr(self.config, field) - for field in fields - if getattr(self.config, field) is not None - } - - -class _PluginConfig(BaseModel): - model_config = ConfigDict(extra="forbid") - - -class _OAuthProxyConfig(_PluginConfig): - base_url: AnyHttpUrl | str | None = None - resource_base_url: AnyHttpUrl | str | None = None - issuer_url: AnyHttpUrl | str | None = None - redirect_path: str | None = None - required_scopes: list[str] | None = None - allowed_client_redirect_uris: list[str] | None = None - jwt_signing_key: str | None = None - require_authorization_consent: ConsentMode = True - consent_csp_policy: str | None = None - forward_resource: bool = True - - -class _OAuthProviderConfig(_OAuthProxyConfig): - client_id: str | None = None - client_secret: str | None = None - timeout_seconds: int = 10 - enable_cimd: bool = True - - -class _RemoteAuthConfig(_PluginConfig): - base_url: AnyHttpUrl | str | None = None - required_scopes: list[str] | None = None - scopes_supported: list[str] | None = None - resource_name: str | None = None - resource_documentation: AnyHttpUrl | None = None - - -class Auth0AuthConfig(_OAuthProxyConfig): - """Config model for the Auth0 auth plugin.""" - - config_url: AnyHttpUrl | str | None = None - client_id: str | None = None - client_secret: str | None = None - audience: str | None = None - - -class Auth0Auth(_AuthPlugin[Auth0AuthConfig]): - """Contribute an `Auth0Provider` as the server's auth provider.""" - - meta = PluginMeta(name="auth0-auth") - - def __init__( - self, - config: Auth0AuthConfig | dict[str, Any] | None = None, - *, - client_storage: AsyncKeyValue | None = None, - ) -> None: - super().__init__(config) - self._client_storage = client_storage - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.auth0 import Auth0Provider - - self._require( - "config_url", "client_id", "client_secret", "audience", "base_url" - ) - return Auth0Provider( - **self._kwargs( - "config_url", - "client_id", - "client_secret", - "audience", - "base_url", - "resource_base_url", - "issuer_url", - "required_scopes", - "redirect_path", - "allowed_client_redirect_uris", - "jwt_signing_key", - "require_authorization_consent", - "consent_csp_policy", - "forward_resource", - ), - client_storage=self._client_storage, - ) - - -class AuthKitAuthConfig(_RemoteAuthConfig): - """Config model for the WorkOS AuthKit auth plugin.""" - - authkit_domain: AnyHttpUrl | str | None = None - resource_base_url: AnyHttpUrl | str | None = None - - -class AuthKitAuth(_AuthPlugin[AuthKitAuthConfig]): - """Contribute an `AuthKitProvider` as the server's auth provider.""" - - meta = PluginMeta(name="authkit-auth") - - def __init__( - self, - config: AuthKitAuthConfig | dict[str, Any] | None = None, - *, - token_verifier: TokenVerifier | None = None, - ) -> None: - super().__init__(config) - self._token_verifier = token_verifier - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.workos import AuthKitProvider - - self._require("authkit_domain", "base_url") - return AuthKitProvider( - **self._kwargs( - "authkit_domain", - "base_url", - "resource_base_url", - "required_scopes", - "scopes_supported", - "resource_name", - "resource_documentation", - ), - token_verifier=self._token_verifier, - ) - - -class AWSCognitoAuthConfig(_OAuthProxyConfig): - """Config model for the AWS Cognito auth plugin.""" - - user_pool_id: str | None = None - client_id: str | None = None - client_secret: str | None = None - aws_region: str = "eu-central-1" - redirect_path: str | None = "/auth/callback" - - -class AWSCognitoAuth(_AuthPlugin[AWSCognitoAuthConfig]): - """Contribute an `AWSCognitoProvider` as the server's auth provider.""" - - meta = PluginMeta(name="aws-cognito-auth") - - def __init__( - self, - config: AWSCognitoAuthConfig | dict[str, Any] | None = None, - *, - client_storage: AsyncKeyValue | None = None, - ) -> None: - super().__init__(config) - self._client_storage = client_storage - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.aws import AWSCognitoProvider - - self._require("user_pool_id", "client_id", "client_secret", "base_url") - return AWSCognitoProvider( - **self._kwargs( - "user_pool_id", - "client_id", - "client_secret", - "base_url", - "resource_base_url", - "aws_region", - "issuer_url", - "redirect_path", - "required_scopes", - "allowed_client_redirect_uris", - "jwt_signing_key", - "require_authorization_consent", - "consent_csp_policy", - "forward_resource", - ), - client_storage=self._client_storage, - ) - - -class AzureAuthConfig(_OAuthProviderConfig): - """Config model for the Azure auth plugin.""" - - tenant_id: str | None = None - required_scopes: list[str] | None = None - identifier_uri: str | None = None - additional_authorize_scopes: list[str] | None = None - base_authority: str = "login.microsoftonline.com" - - -class AzureAuth(_AuthPlugin[AzureAuthConfig]): - """Contribute an `AzureProvider` as the server's auth provider.""" - - meta = PluginMeta(name="azure-auth") - - def __init__( - self, - config: AzureAuthConfig | dict[str, Any] | None = None, - *, - client_storage: AsyncKeyValue | None = None, - http_client: httpx.AsyncClient | None = None, - ) -> None: - super().__init__(config) - self._client_storage = client_storage - self._http_client = http_client - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.azure import AzureProvider - - self._require("client_id", "tenant_id", "required_scopes", "base_url") - self._require_one("client_secret", "jwt_signing_key") - return AzureProvider( - **self._kwargs( - "client_id", - "client_secret", - "tenant_id", - "required_scopes", - "base_url", - "resource_base_url", - "identifier_uri", - "issuer_url", - "redirect_path", - "additional_authorize_scopes", - "allowed_client_redirect_uris", - "jwt_signing_key", - "require_authorization_consent", - "consent_csp_policy", - "forward_resource", - "base_authority", - "enable_cimd", - ), - client_storage=self._client_storage, - http_client=self._http_client, - ) - - -class ClerkAuthConfig(_OAuthProviderConfig): - """Config model for the Clerk auth plugin.""" - - domain: str | None = None - valid_scopes: list[str] | None = None - extra_authorize_params: dict[str, str] | None = None - - -class ClerkAuth(_AuthPlugin[ClerkAuthConfig]): - """Contribute a `ClerkProvider` as the server's auth provider.""" - - meta = PluginMeta(name="clerk-auth") - - def __init__( - self, - config: ClerkAuthConfig | dict[str, Any] | None = None, - *, - client_storage: AsyncKeyValue | None = None, - http_client: httpx.AsyncClient | None = None, - ) -> None: - super().__init__(config) - self._client_storage = client_storage - self._http_client = http_client - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.clerk import ClerkProvider - - self._require("domain", "client_id", "base_url") - self._require_one("client_secret", "jwt_signing_key") - return ClerkProvider( - **self._kwargs( - "domain", - "client_id", - "client_secret", - "base_url", - "resource_base_url", - "issuer_url", - "redirect_path", - "required_scopes", - "valid_scopes", - "timeout_seconds", - "allowed_client_redirect_uris", - "jwt_signing_key", - "require_authorization_consent", - "consent_csp_policy", - "forward_resource", - "extra_authorize_params", - "enable_cimd", - ), - client_storage=self._client_storage, - http_client=self._http_client, - ) - - -class DescopeAuthConfig(_RemoteAuthConfig): - """Config model for the Descope auth plugin.""" - - config_url: AnyHttpUrl | str | None = None - project_id: str | None = None - descope_base_url: AnyHttpUrl | str | None = None - - -class DescopeAuth(_AuthPlugin[DescopeAuthConfig]): - """Contribute a `DescopeProvider` as the server's auth provider.""" - - meta = PluginMeta(name="descope-auth") - - def __init__( - self, - config: DescopeAuthConfig | dict[str, Any] | None = None, - *, - token_verifier: TokenVerifier | None = None, - ) -> None: - super().__init__(config) - self._token_verifier = token_verifier - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.descope import DescopeProvider - - self._require("base_url") - if self.config.config_url is None: - self._require("project_id", "descope_base_url") - return DescopeProvider( - **self._kwargs( - "base_url", - "config_url", - "project_id", - "descope_base_url", - "required_scopes", - "scopes_supported", - "resource_name", - "resource_documentation", - ), - token_verifier=self._token_verifier, - ) - - -class DiscordAuthConfig(_OAuthProviderConfig): - """Config model for the Discord auth plugin.""" - - -class DiscordAuth(_AuthPlugin[DiscordAuthConfig]): - """Contribute a `DiscordProvider` as the server's auth provider.""" - - meta = PluginMeta(name="discord-auth") - - def __init__( - self, - config: DiscordAuthConfig | dict[str, Any] | None = None, - *, - client_storage: AsyncKeyValue | None = None, - http_client: httpx.AsyncClient | None = None, - ) -> None: - super().__init__(config) - self._client_storage = client_storage - self._http_client = http_client - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.discord import DiscordProvider - - self._require("client_id", "client_secret", "base_url") - return DiscordProvider( - **self._kwargs( - "client_id", - "client_secret", - "base_url", - "resource_base_url", - "issuer_url", - "redirect_path", - "required_scopes", - "timeout_seconds", - "allowed_client_redirect_uris", - "jwt_signing_key", - "require_authorization_consent", - "consent_csp_policy", - "forward_resource", - "enable_cimd", - ), - client_storage=self._client_storage, - http_client=self._http_client, - ) - - -class GitHubAuthConfig(_OAuthProviderConfig): - """Config model for the GitHub auth plugin.""" - - cache_ttl_seconds: int | None = None - max_cache_size: int | None = None - - -class GitHubAuth(_AuthPlugin[GitHubAuthConfig]): - """Contribute a `GitHubProvider` as the server's auth provider.""" - - meta = PluginMeta(name="github-auth") - - def __init__( - self, - config: GitHubAuthConfig | dict[str, Any] | None = None, - *, - client_storage: AsyncKeyValue | None = None, - http_client: httpx.AsyncClient | None = None, - ) -> None: - super().__init__(config) - self._client_storage = client_storage - self._http_client = http_client - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.github import GitHubProvider - - self._require("client_id", "client_secret", "base_url") - return GitHubProvider( - **self._kwargs( - "client_id", - "client_secret", - "base_url", - "resource_base_url", - "issuer_url", - "redirect_path", - "required_scopes", - "timeout_seconds", - "cache_ttl_seconds", - "max_cache_size", - "allowed_client_redirect_uris", - "jwt_signing_key", - "require_authorization_consent", - "consent_csp_policy", - "forward_resource", - "enable_cimd", - ), - client_storage=self._client_storage, - http_client=self._http_client, - ) - - -class GoogleAuthConfig(_OAuthProviderConfig): - """Config model for the Google auth plugin.""" - - valid_scopes: list[str] | None = None - extra_authorize_params: dict[str, str] | None = None - - -class GoogleAuth(_AuthPlugin[GoogleAuthConfig]): - """Contribute a `GoogleProvider` as the server's auth provider.""" - - meta = PluginMeta(name="google-auth") - - def __init__( - self, - config: GoogleAuthConfig | dict[str, Any] | None = None, - *, - client_storage: AsyncKeyValue | None = None, - http_client: httpx.AsyncClient | None = None, - ) -> None: - super().__init__(config) - self._client_storage = client_storage - self._http_client = http_client - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.google import GoogleProvider - - self._require("client_id", "base_url") - self._require_one("client_secret", "jwt_signing_key") - return GoogleProvider( - **self._kwargs( - "client_id", - "client_secret", - "base_url", - "resource_base_url", - "issuer_url", - "redirect_path", - "required_scopes", - "valid_scopes", - "timeout_seconds", - "allowed_client_redirect_uris", - "jwt_signing_key", - "require_authorization_consent", - "consent_csp_policy", - "forward_resource", - "extra_authorize_params", - "enable_cimd", - ), - client_storage=self._client_storage, - http_client=self._http_client, - ) - - -class KeycloakAuthConfig(_PluginConfig): - """Config model for the Keycloak auth plugin.""" - - realm_url: AnyHttpUrl | str | None = None - base_url: AnyHttpUrl | str | None = None - required_scopes: list[str] | str | None = None - audience: str | list[str] | None = None - - -class KeycloakAuth(_AuthPlugin[KeycloakAuthConfig]): - """Contribute a `KeycloakAuthProvider` as the server's auth provider.""" - - meta = PluginMeta(name="keycloak-auth") - - def __init__( - self, - config: KeycloakAuthConfig | dict[str, Any] | None = None, - *, - token_verifier: TokenVerifier | None = None, - ) -> None: - super().__init__(config) - self._token_verifier = token_verifier - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider - - self._require("realm_url", "base_url") - return KeycloakAuthProvider( - **self._kwargs("realm_url", "base_url", "required_scopes", "audience"), - token_verifier=self._token_verifier, - ) - - -class OCIAuthConfig(_OAuthProxyConfig): - """Config model for the OCI auth plugin.""" - - config_url: AnyHttpUrl | str | None = None - client_id: str | None = None - client_secret: str | None = None - audience: str | None = None - - -class OCIAuth(_AuthPlugin[OCIAuthConfig]): - """Contribute an `OCIProvider` as the server's auth provider.""" - - meta = PluginMeta(name="oci-auth") - - def __init__( - self, - config: OCIAuthConfig | dict[str, Any] | None = None, - *, - client_storage: AsyncKeyValue | None = None, - ) -> None: - super().__init__(config) - self._client_storage = client_storage - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.oci import OCIProvider - - self._require("config_url", "client_id", "client_secret", "base_url") - return OCIProvider( - **self._kwargs( - "config_url", - "client_id", - "client_secret", - "base_url", - "resource_base_url", - "audience", - "issuer_url", - "required_scopes", - "redirect_path", - "allowed_client_redirect_uris", - "jwt_signing_key", - "require_authorization_consent", - "consent_csp_policy", - "forward_resource", - ), - client_storage=self._client_storage, - ) - - -class PropelAuthConfig(_RemoteAuthConfig): - """Config model for the PropelAuth auth plugin.""" - - auth_url: AnyHttpUrl | str | None = None - introspection_client_id: str | None = None - introspection_client_secret: str | None = None - resource: AnyHttpUrl | str | None = None - introspection_timeout_seconds: int | None = None - introspection_cache_ttl_seconds: int | None = None - introspection_max_cache_size: int | None = None - - -class PropelAuth(_AuthPlugin[PropelAuthConfig]): - """Contribute a `PropelAuthProvider` as the server's auth provider.""" - - meta = PluginMeta(name="propelauth-auth") - - def __init__( - self, - config: PropelAuthConfig | dict[str, Any] | None = None, - *, - http_client: httpx.AsyncClient | None = None, - ) -> None: - super().__init__(config) - self._http_client = http_client - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.propelauth import ( - PropelAuthProvider, - PropelAuthTokenIntrospectionOverrides, - ) - - self._require( - "auth_url", - "introspection_client_id", - "introspection_client_secret", - "base_url", - ) - overrides: PropelAuthTokenIntrospectionOverrides = {} - if self.config.introspection_timeout_seconds is not None: - overrides["timeout_seconds"] = self.config.introspection_timeout_seconds - if self.config.introspection_cache_ttl_seconds is not None: - overrides["cache_ttl_seconds"] = self.config.introspection_cache_ttl_seconds - if self.config.introspection_max_cache_size is not None: - overrides["max_cache_size"] = self.config.introspection_max_cache_size - if self._http_client is not None: - overrides["http_client"] = self._http_client - - return PropelAuthProvider( - **self._kwargs( - "auth_url", - "introspection_client_id", - "introspection_client_secret", - "base_url", - "required_scopes", - "scopes_supported", - "resource_name", - "resource_documentation", - "resource", - ), - token_introspection_overrides=overrides or None, - ) - - -class ScalekitAuthConfig(_RemoteAuthConfig): - """Config model for the Scalekit auth plugin.""" - - environment_url: AnyHttpUrl | str | None = None - resource_id: str | None = None - mcp_url: AnyHttpUrl | str | None = None - client_id: str | None = None - - -class ScalekitAuth(_AuthPlugin[ScalekitAuthConfig]): - """Contribute a `ScalekitProvider` as the server's auth provider.""" - - meta = PluginMeta(name="scalekit-auth") - - def __init__( - self, - config: ScalekitAuthConfig | dict[str, Any] | None = None, - *, - token_verifier: TokenVerifier | None = None, - ) -> None: - super().__init__(config) - self._token_verifier = token_verifier - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.scalekit import ScalekitProvider - - self._require("environment_url", "resource_id") - self._require_one("base_url", "mcp_url") - return ScalekitProvider( - **self._kwargs( - "environment_url", - "resource_id", - "base_url", - "mcp_url", - "client_id", - "required_scopes", - "scopes_supported", - "resource_name", - "resource_documentation", - ), - token_verifier=self._token_verifier, - ) - - -class SupabaseAuthConfig(_RemoteAuthConfig): - """Config model for the Supabase auth plugin.""" - - project_url: AnyHttpUrl | str | None = None - auth_route: str = "/auth/v1" - algorithm: Algorithm = "ES256" - - -class SupabaseAuth(_AuthPlugin[SupabaseAuthConfig]): - """Contribute a `SupabaseProvider` as the server's auth provider.""" - - meta = PluginMeta(name="supabase-auth") - - def __init__( - self, - config: SupabaseAuthConfig | dict[str, Any] | None = None, - *, - token_verifier: TokenVerifier | None = None, - ) -> None: - super().__init__(config) - self._token_verifier = token_verifier - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.supabase import SupabaseProvider - - self._require("project_url", "base_url") - return SupabaseProvider( - **self._kwargs( - "project_url", - "base_url", - "auth_route", - "algorithm", - "required_scopes", - "scopes_supported", - "resource_name", - "resource_documentation", - ), - token_verifier=self._token_verifier, - ) - - -class WorkOSAuthConfig(_OAuthProviderConfig): - """Config model for the WorkOS auth plugin.""" - - authkit_domain: str | None = None - - -class WorkOSAuth(_AuthPlugin[WorkOSAuthConfig]): - """Contribute a `WorkOSProvider` as the server's auth provider.""" - - meta = PluginMeta(name="workos-auth") - - def __init__( - self, - config: WorkOSAuthConfig | dict[str, Any] | None = None, - *, - client_storage: AsyncKeyValue | None = None, - http_client: httpx.AsyncClient | None = None, - ) -> None: - super().__init__(config) - self._client_storage = client_storage - self._http_client = http_client - - def auth(self) -> AuthProvider | None: - from fastmcp.server.auth.providers.workos import WorkOSProvider - - self._require("client_id", "client_secret", "authkit_domain", "base_url") - return WorkOSProvider( - **self._kwargs( - "client_id", - "client_secret", - "authkit_domain", - "base_url", - "resource_base_url", - "issuer_url", - "redirect_path", - "required_scopes", - "timeout_seconds", - "allowed_client_redirect_uris", - "jwt_signing_key", - "require_authorization_consent", - "consent_csp_policy", - "forward_resource", - "enable_cimd", - ), - client_storage=self._client_storage, - http_client=self._http_client, - ) diff --git a/src/fastmcp/server/plugins/auth/scalekit/__init__.py b/src/fastmcp/server/plugins/auth/scalekit/__init__.py new file mode 100644 index 000000000..bad3fc3b1 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/scalekit/__init__.py @@ -0,0 +1,5 @@ +"""Scalekit auth plugin.""" + +from fastmcp.server.plugins.auth.scalekit.plugin import ScalekitAuth + +__all__ = ["ScalekitAuth"] diff --git a/src/fastmcp/server/plugins/auth/scalekit/plugin.py b/src/fastmcp/server/plugins/auth/scalekit/plugin.py new file mode 100644 index 000000000..ab4ac2553 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/scalekit/plugin.py @@ -0,0 +1,56 @@ +"""Scalekit auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import AuthProvider, TokenVerifier +from fastmcp.server.plugins.auth._base import AuthPlugin, RemoteAuthConfig +from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider +from fastmcp.server.plugins.base import PluginMeta + + +class ScalekitAuthConfig(RemoteAuthConfig): + """Config model for the Scalekit auth plugin.""" + + environment_url: AnyHttpUrl | str | None = None + resource_id: str | None = None + mcp_url: AnyHttpUrl | str | None = None + client_id: str | None = None + + +class ScalekitAuth(AuthPlugin[ScalekitAuthConfig]): + """Contribute a `ScalekitProvider` as the server's auth provider.""" + + Config: ClassVar[type[ScalekitAuthConfig]] = ScalekitAuthConfig + + meta = PluginMeta(name="scalekit-auth") + + def __init__( + self, + config: ScalekitAuthConfig | dict[str, Any] | None = None, + *, + token_verifier: TokenVerifier | None = None, + ) -> None: + super().__init__(config) + self._token_verifier = token_verifier + + def auth(self) -> AuthProvider | None: + self._require("environment_url", "resource_id") + self._require_one("base_url", "mcp_url") + return ScalekitProvider( + **self._kwargs( + "environment_url", + "resource_id", + "base_url", + "mcp_url", + "client_id", + "required_scopes", + "scopes_supported", + "resource_name", + "resource_documentation", + ), + token_verifier=self._token_verifier, + ) diff --git a/src/fastmcp/server/plugins/auth/scalekit/provider.py b/src/fastmcp/server/plugins/auth/scalekit/provider.py new file mode 100644 index 000000000..c64abada4 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/scalekit/provider.py @@ -0,0 +1,212 @@ +"""Scalekit authentication provider for FastMCP. + +This module provides ScalekitProvider - a complete authentication solution that integrates +with Scalekit's OAuth 2.1 and OpenID Connect services, supporting Resource Server +authentication for seamless MCP client authentication. +""" + +from __future__ import annotations + +import httpx +from pydantic import AnyHttpUrl +from starlette.responses import JSONResponse +from starlette.routing import Route + +from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class ScalekitProvider(RemoteAuthProvider): + """Scalekit resource server provider for OAuth 2.1 authentication. + + This provider implements Scalekit integration using resource server pattern. + FastMCP acts as a protected resource server that validates access tokens issued + by Scalekit's authorization server. + + IMPORTANT SETUP REQUIREMENTS: + + 1. Create an MCP Server in Scalekit Dashboard: + - Go to your [Scalekit Dashboard](https://app.scalekit.com/) + - Navigate to MCP Servers section + - Register a new MCP Server with appropriate scopes + - Ensure the Resource Identifier matches exactly what you configure as MCP URL + - Note the Resource ID + + 2. Environment Configuration: + - Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com) + - Set SCALEKIT_RESOURCE_ID from your created resource + - Set BASE_URL to your FastMCP server's public URL + + For detailed setup instructions, see: + https://docs.scalekit.com/mcp/overview/ + + Example: + ```python + from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider + + # Create Scalekit resource server provider + scalekit_auth = ScalekitProvider( + environment_url="https://your-env.scalekit.com", + resource_id="sk_resource_...", + base_url="https://your-fastmcp-server.com", + ) + + # Use with FastMCP + mcp = FastMCP("My App", auth=scalekit_auth) + ``` + """ + + def __init__( + self, + *, + environment_url: AnyHttpUrl | str, + resource_id: str, + base_url: AnyHttpUrl | str | None = None, + mcp_url: AnyHttpUrl | str | None = None, + client_id: str | None = None, + required_scopes: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, + token_verifier: TokenVerifier | None = None, + ): + """Initialize Scalekit resource server provider. + + Args: + environment_url: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com") + resource_id: Your Scalekit resource ID + base_url: Public URL of this FastMCP server (or use mcp_url for backwards compatibility) + mcp_url: Deprecated alias for base_url. Will be removed in a future release. + client_id: Deprecated parameter, no longer required. Will be removed in a future release. + required_scopes: Optional list of scopes that must be present in tokens + scopes_supported: Optional list of scopes to advertise in OAuth metadata. + If None, uses required_scopes. Use this when the scopes clients should + request differ from the scopes enforced on tokens. + resource_name: Optional name for the protected resource metadata. + resource_documentation: Optional documentation URL for the protected resource. + token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit + """ + # Resolve base_url from mcp_url if needed (backwards compatibility) + resolved_base_url = base_url or mcp_url + if not resolved_base_url: + raise ValueError("Either base_url or mcp_url must be provided") + + if mcp_url is not None: + logger.warning( + "ScalekitProvider parameter 'mcp_url' is deprecated and will be removed in a future release. " + "Rename it to 'base_url'." + ) + + if client_id is not None: + logger.warning( + "ScalekitProvider no longer requires 'client_id'. The parameter is accepted only for backward " + "compatibility and will be removed in a future release." + ) + + self.environment_url = str(environment_url).rstrip("/") + self.resource_id = resource_id + parsed_scopes = ( + parse_scopes(required_scopes) if required_scopes is not None else [] + ) + self.required_scopes = parsed_scopes + base_url_value = str(resolved_base_url) + + logger.debug( + "Initializing ScalekitProvider: environment_url=%s resource_id=%s base_url=%s required_scopes=%s", + self.environment_url, + self.resource_id, + base_url_value, + self.required_scopes, + ) + + # Create default JWT verifier if none provided + if token_verifier is None: + logger.debug( + "Creating default JWTVerifier for Scalekit: jwks_uri=%s issuer=%s required_scopes=%s", + f"{self.environment_url}/keys", + self.environment_url, + self.required_scopes, + ) + token_verifier = JWTVerifier( + jwks_uri=f"{self.environment_url}/keys", + issuer=self.environment_url, + algorithm="RS256", + audience=self.resource_id, + required_scopes=self.required_scopes or None, + ) + else: + logger.debug("Using custom token verifier for ScalekitProvider") + + # Initialize RemoteAuthProvider with Scalekit as the authorization server + super().__init__( + token_verifier=token_verifier, + authorization_servers=[ + AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}") + ], + base_url=base_url_value, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, + ) + + def get_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get OAuth routes including Scalekit authorization server metadata forwarding. + + This returns the standard protected resource routes plus an authorization server + metadata endpoint that forwards Scalekit's OAuth metadata to clients. + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + This is used to advertise the resource URL in metadata. + """ + # Get the standard protected resource routes from RemoteAuthProvider + routes = super().get_routes(mcp_path) + logger.debug( + "Preparing Scalekit metadata routes: mcp_path=%s resource_id=%s", + mcp_path, + self.resource_id, + ) + + async def oauth_authorization_server_metadata(request): + """Forward Scalekit OAuth authorization server metadata with FastMCP customizations.""" + try: + metadata_url = f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}" + logger.debug( + "Fetching Scalekit OAuth metadata: metadata_url=%s", metadata_url + ) + async with httpx.AsyncClient() as client: + response = await client.get(metadata_url) + response.raise_for_status() + metadata = response.json() + logger.debug( + "Scalekit metadata fetched successfully: metadata_keys=%s", + list(metadata.keys()), + ) + return JSONResponse(metadata) + except Exception as e: + logger.error(f"Failed to fetch Scalekit metadata: {e}") + return JSONResponse( + { + "error": "server_error", + "error_description": f"Failed to fetch Scalekit metadata: {e}", + }, + status_code=500, + ) + + # Add Scalekit authorization server metadata forwarding + routes.append( + Route( + "/.well-known/oauth-authorization-server", + endpoint=oauth_authorization_server_metadata, + methods=["GET"], + ) + ) + + return routes diff --git a/src/fastmcp/server/plugins/auth/supabase.py b/src/fastmcp/server/plugins/auth/supabase.py deleted file mode 100644 index debbcbac0..000000000 --- a/src/fastmcp/server/plugins/auth/supabase.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Supabase auth plugin.""" - -from fastmcp.server.plugins.auth.providers import SupabaseAuth, SupabaseAuthConfig - -__all__ = ["SupabaseAuth", "SupabaseAuthConfig"] diff --git a/src/fastmcp/server/plugins/auth/supabase/__init__.py b/src/fastmcp/server/plugins/auth/supabase/__init__.py new file mode 100644 index 000000000..e2367f17c --- /dev/null +++ b/src/fastmcp/server/plugins/auth/supabase/__init__.py @@ -0,0 +1,5 @@ +"""Supabase auth plugin.""" + +from fastmcp.server.plugins.auth.supabase.plugin import SupabaseAuth + +__all__ = ["SupabaseAuth"] diff --git a/src/fastmcp/server/plugins/auth/supabase/plugin.py b/src/fastmcp/server/plugins/auth/supabase/plugin.py new file mode 100644 index 000000000..8ecf979e9 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/supabase/plugin.py @@ -0,0 +1,53 @@ +"""Supabase auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import AuthProvider, TokenVerifier +from fastmcp.server.plugins.auth._base import Algorithm, AuthPlugin, RemoteAuthConfig +from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider +from fastmcp.server.plugins.base import PluginMeta + + +class SupabaseAuthConfig(RemoteAuthConfig): + """Config model for the Supabase auth plugin.""" + + project_url: AnyHttpUrl | str | None = None + auth_route: str = "/auth/v1" + algorithm: Algorithm = "ES256" + + +class SupabaseAuth(AuthPlugin[SupabaseAuthConfig]): + """Contribute a `SupabaseProvider` as the server's auth provider.""" + + Config: ClassVar[type[SupabaseAuthConfig]] = SupabaseAuthConfig + + meta = PluginMeta(name="supabase-auth") + + def __init__( + self, + config: SupabaseAuthConfig | dict[str, Any] | None = None, + *, + token_verifier: TokenVerifier | None = None, + ) -> None: + super().__init__(config) + self._token_verifier = token_verifier + + def auth(self) -> AuthProvider | None: + self._require("project_url", "base_url") + return SupabaseProvider( + **self._kwargs( + "project_url", + "base_url", + "auth_route", + "algorithm", + "required_scopes", + "scopes_supported", + "resource_name", + "resource_documentation", + ), + token_verifier=self._token_verifier, + ) diff --git a/src/fastmcp/server/plugins/auth/supabase/provider.py b/src/fastmcp/server/plugins/auth/supabase/provider.py new file mode 100644 index 000000000..1631df35a --- /dev/null +++ b/src/fastmcp/server/plugins/auth/supabase/provider.py @@ -0,0 +1,181 @@ +"""Supabase authentication provider for FastMCP. + +This module provides SupabaseProvider - a complete authentication solution that integrates +with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR) +for seamless MCP client authentication. +""" + +from __future__ import annotations + +from typing import Literal + +import httpx +from pydantic import AnyHttpUrl +from starlette.responses import JSONResponse +from starlette.routing import Route + +from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier +from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class SupabaseProvider(RemoteAuthProvider): + """Supabase metadata provider for DCR (Dynamic Client Registration). + + This provider implements Supabase Auth integration using metadata forwarding. + This approach allows Supabase to handle the OAuth flow directly while FastMCP acts + as a resource server, verifying JWTs issued by Supabase Auth. + + IMPORTANT SETUP REQUIREMENTS: + + 1. Supabase Project Setup: + - Create a Supabase project at https://supabase.com + - Note your project URL (e.g., "https://abc123.supabase.co") + - Configure your JWT algorithm in Supabase Auth settings (RS256 or ES256) + - Asymmetric keys (RS256/ES256) are recommended for production + + 2. JWT Verification: + - FastMCP verifies JWTs using the JWKS endpoint at {project_url}{auth_route}/.well-known/jwks.json + - JWTs are issued by {project_url}{auth_route} + - Default auth_route is "/auth/v1" (can be customized for self-hosted setups) + - Tokens are cached for up to 10 minutes by Supabase's edge servers + - Algorithm must match your Supabase Auth configuration + + 3. Authorization: + - Supabase uses Row Level Security (RLS) policies for database authorization + - OAuth-level scopes are an upcoming feature in Supabase Auth + - Both approaches will be supported once scope handling is available + + For detailed setup instructions, see: + https://supabase.com/docs/guides/auth/jwts + + Example: + ```python + from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider + + # Create Supabase metadata provider (JWT verifier created automatically) + supabase_auth = SupabaseProvider( + project_url="https://abc123.supabase.co", + base_url="https://your-fastmcp-server.com", + algorithm="ES256", # Match your Supabase Auth configuration + ) + + # Use with FastMCP + mcp = FastMCP("My App", auth=supabase_auth) + ``` + """ + + def __init__( + self, + *, + project_url: AnyHttpUrl | str, + base_url: AnyHttpUrl | str, + auth_route: str = "/auth/v1", + algorithm: Literal["RS256", "ES256"] = "ES256", + required_scopes: list[str] | None = None, + scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, + token_verifier: TokenVerifier | None = None, + ): + """Initialize Supabase metadata provider. + + Args: + project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co") + base_url: Public URL of this FastMCP server + auth_route: Supabase Auth route. Defaults to "/auth/v1". Can be customized + for self-hosted Supabase Auth setups using custom routes. + algorithm: JWT signing algorithm (RS256 or ES256). Must match your + Supabase Auth configuration. Defaults to ES256. + required_scopes: Optional list of scopes to require for all requests. + Note: Supabase currently uses RLS policies for authorization. OAuth-level + scopes are an upcoming feature. + scopes_supported: Optional list of scopes to advertise in OAuth metadata. + If None, uses required_scopes. Use this when the scopes clients should + request differ from the scopes enforced on tokens. + resource_name: Optional name for the protected resource metadata. + resource_documentation: Optional documentation URL for the protected resource. + token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase + """ + self.project_url = str(project_url).rstrip("/") + self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) + self.auth_route = auth_route.strip("/") + + # Parse scopes if provided as string + parsed_scopes = ( + parse_scopes(required_scopes) if required_scopes is not None else None + ) + + # Create default JWT verifier if none provided + if token_verifier is None: + logger.warning( + "SupabaseProvider cannot validate token audience for the specific resource " + "because Supabase Auth does not support RFC 8707 resource indicators. " + "This may leave the server vulnerable to cross-server token replay." + ) + token_verifier = JWTVerifier( + jwks_uri=f"{self.project_url}/{self.auth_route}/.well-known/jwks.json", + issuer=f"{self.project_url}/{self.auth_route}", + algorithm=algorithm, + audience="authenticated", + required_scopes=parsed_scopes, + ) + + # Initialize RemoteAuthProvider with Supabase as the authorization server + super().__init__( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl(f"{self.project_url}/{self.auth_route}")], + base_url=self.base_url, + scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, + ) + + def get_routes( + self, + mcp_path: str | None = None, + ) -> list[Route]: + """Get OAuth routes including Supabase authorization server metadata forwarding. + + This returns the standard protected resource routes plus an authorization server + metadata endpoint that forwards Supabase's OAuth metadata to clients. + + Args: + mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") + This is used to advertise the resource URL in metadata. + """ + # Get the standard protected resource routes from RemoteAuthProvider + routes = super().get_routes(mcp_path) + + async def oauth_authorization_server_metadata(request): + """Forward Supabase OAuth authorization server metadata with FastMCP customizations.""" + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.project_url}/{self.auth_route}/.well-known/oauth-authorization-server" + ) + response.raise_for_status() + metadata = response.json() + return JSONResponse(metadata) + except Exception as e: + return JSONResponse( + { + "error": "server_error", + "error_description": f"Failed to fetch Supabase metadata: {e}", + }, + status_code=500, + ) + + # Add Supabase authorization server metadata forwarding + routes.append( + Route( + "/.well-known/oauth-authorization-server", + endpoint=oauth_authorization_server_metadata, + methods=["GET"], + ) + ) + + return routes diff --git a/src/fastmcp/server/plugins/auth/workos/__init__.py b/src/fastmcp/server/plugins/auth/workos/__init__.py new file mode 100644 index 000000000..962652a99 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/workos/__init__.py @@ -0,0 +1,5 @@ +"""WorkOS auth plugin.""" + +from fastmcp.server.plugins.auth.workos.plugin import WorkOSAuth + +__all__ = ["WorkOSAuth"] diff --git a/src/fastmcp/server/plugins/auth/workos/plugin.py b/src/fastmcp/server/plugins/auth/workos/plugin.py new file mode 100644 index 000000000..a28cf4526 --- /dev/null +++ b/src/fastmcp/server/plugins/auth/workos/plugin.py @@ -0,0 +1,62 @@ +"""WorkOS auth plugin.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import httpx +from key_value.aio.protocols import AsyncKeyValue + +from fastmcp.server.auth import AuthProvider +from fastmcp.server.plugins.auth._base import AuthPlugin, OAuthProviderConfig +from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider +from fastmcp.server.plugins.base import PluginMeta + + +class WorkOSAuthConfig(OAuthProviderConfig): + """Config model for the WorkOS auth plugin.""" + + authkit_domain: str | None = None + + +class WorkOSAuth(AuthPlugin[WorkOSAuthConfig]): + """Contribute a `WorkOSProvider` as the server's auth provider.""" + + Config: ClassVar[type[WorkOSAuthConfig]] = WorkOSAuthConfig + + meta = PluginMeta(name="workos-auth") + + def __init__( + self, + config: WorkOSAuthConfig | dict[str, Any] | None = None, + *, + client_storage: AsyncKeyValue | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__(config) + self._client_storage = client_storage + self._http_client = http_client + + def auth(self) -> AuthProvider | None: + self._require("client_id", "client_secret", "authkit_domain", "base_url") + return WorkOSProvider( + **self._kwargs( + "client_id", + "client_secret", + "authkit_domain", + "base_url", + "resource_base_url", + "issuer_url", + "redirect_path", + "required_scopes", + "timeout_seconds", + "allowed_client_redirect_uris", + "jwt_signing_key", + "require_authorization_consent", + "consent_csp_policy", + "forward_resource", + "enable_cimd", + ), + client_storage=self._client_storage, + http_client=self._http_client, + ) diff --git a/src/fastmcp/server/plugins/auth/workos/provider.py b/src/fastmcp/server/plugins/auth/workos/provider.py new file mode 100644 index 000000000..16d94539b --- /dev/null +++ b/src/fastmcp/server/plugins/auth/workos/provider.py @@ -0,0 +1,245 @@ +"""WorkOS OAuth authentication provider for FastMCP.""" + +from __future__ import annotations + +import contextlib +from typing import Literal + +import httpx +from key_value.aio.protocols import AsyncKeyValue +from pydantic import AnyHttpUrl + +from fastmcp.server.auth import AccessToken, TokenVerifier +from fastmcp.server.auth.oauth_proxy import OAuthProxy +from fastmcp.utilities.auth import parse_scopes +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + + +class WorkOSTokenVerifier(TokenVerifier): + """Token verifier for WorkOS OAuth tokens. + + WorkOS AuthKit tokens are opaque, so we verify them by calling + the /oauth2/userinfo endpoint to check validity and get user info. + """ + + def __init__( + self, + *, + authkit_domain: str, + required_scopes: list[str] | None = None, + timeout_seconds: int = 10, + http_client: httpx.AsyncClient | None = None, + ): + """Initialize the WorkOS token verifier. + + Args: + authkit_domain: WorkOS AuthKit domain (e.g., "https://your-app.authkit.app") + required_scopes: Required OAuth scopes + timeout_seconds: HTTP request timeout + http_client: Optional httpx.AsyncClient for connection pooling. When provided, + the client is reused across calls and the caller is responsible for its + lifecycle. When None (default), a fresh client is created per call. + """ + super().__init__(required_scopes=required_scopes) + self.authkit_domain = authkit_domain.rstrip("/") + self.timeout_seconds = timeout_seconds + self._http_client = http_client + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify WorkOS OAuth token by calling userinfo endpoint.""" + try: + async with ( + contextlib.nullcontext(self._http_client) + if self._http_client is not None + else httpx.AsyncClient(timeout=self.timeout_seconds) + ) as client: + # Use WorkOS AuthKit userinfo endpoint to validate token + response = await client.get( + f"{self.authkit_domain}/oauth2/userinfo", + headers={ + "Authorization": f"Bearer {token}", + "User-Agent": "FastMCP-WorkOS-OAuth", + }, + ) + + if response.status_code != 200: + logger.debug( + "WorkOS token verification failed: %d - %s", + response.status_code, + response.text[:200], + ) + return None + + user_data = response.json() + token_scopes = ( + parse_scopes(user_data.get("scope") or user_data.get("scopes")) + or [] + ) + + if self.required_scopes and not all( + scope in token_scopes for scope in self.required_scopes + ): + logger.debug( + "WorkOS token missing required scopes. required=%s actual=%s", + self.required_scopes, + token_scopes, + ) + return None + + # Create AccessToken with WorkOS user info + return AccessToken( + token=token, + client_id=str(user_data.get("sub", "unknown")), + scopes=token_scopes, + expires_at=None, # Will be set from token introspection if needed + claims={ + "sub": user_data.get("sub"), + "email": user_data.get("email"), + "email_verified": user_data.get("email_verified"), + "name": user_data.get("name"), + "given_name": user_data.get("given_name"), + "family_name": user_data.get("family_name"), + }, + ) + + except httpx.RequestError as e: + logger.debug("Failed to verify WorkOS token: %s", e) + return None + except Exception as e: + logger.debug("WorkOS token verification error: %s", e) + return None + + +class WorkOSProvider(OAuthProxy): + """Complete WorkOS OAuth provider for FastMCP. + + This provider implements WorkOS AuthKit OAuth using the OAuth Proxy pattern. + It provides OAuth2 authentication for users through WorkOS Connect applications. + + Features: + - Transparent OAuth proxy to WorkOS AuthKit + - Automatic token validation via userinfo endpoint + - User information extraction from ID tokens + - Support for standard OAuth scopes (openid, profile, email) + + Setup Requirements: + 1. Create a WorkOS Connect application in your dashboard + 2. Note your AuthKit domain (e.g., "https://your-app.authkit.app") + 3. Configure redirect URI as: http://localhost:8000/auth/callback + 4. Note your Client ID and Client Secret + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider + + auth = WorkOSProvider( + client_id="client_123", + client_secret="sk_test_456", + authkit_domain="https://your-app.authkit.app", + base_url="http://localhost:8000" + ) + + mcp = FastMCP("My App", auth=auth) + ``` + """ + + def __init__( + self, + *, + client_id: str, + client_secret: str, + authkit_domain: str, + base_url: AnyHttpUrl | str, + resource_base_url: AnyHttpUrl | str | None = None, + issuer_url: AnyHttpUrl | str | None = None, + redirect_path: str | None = None, + required_scopes: list[str] | None = None, + timeout_seconds: int = 10, + allowed_client_redirect_uris: list[str] | None = None, + client_storage: AsyncKeyValue | None = None, + jwt_signing_key: str | bytes | None = None, + require_authorization_consent: bool | Literal["remember", "external"] = True, + consent_csp_policy: str | None = None, + forward_resource: bool = True, + http_client: httpx.AsyncClient | None = None, + enable_cimd: bool = True, + ): + """Initialize WorkOS OAuth provider. + + Args: + client_id: WorkOS client ID + client_secret: WorkOS client secret + authkit_domain: Your WorkOS AuthKit domain (e.g., "https://your-app.authkit.app") + base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) + resource_base_url: Optional public base URL for the protected resource metadata + and token audience. Defaults to ``base_url``. + issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL + to avoid 404s during discovery when mounting under a path. + redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback") + required_scopes: Required OAuth scopes (no default) + timeout_seconds: HTTP request timeout for WorkOS API calls (defaults to 10) + allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. + If None (default), all URIs are allowed. If empty list, no URIs are allowed. + client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). + If None, an encrypted file store will be created in the data directory + (derived from `platformdirs`). + jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, + they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not + provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. + require_authorization_consent: Whether to require user consent before authorizing clients (default True). + When True, users see a consent screen before being redirected to WorkOS. + When False, authorization proceeds directly without user confirmation. + When "external", the built-in consent screen is skipped but no warning is + logged, indicating that consent is handled externally (e.g. by the upstream IdP). + SECURITY WARNING: Only set to False for local development or testing environments. + http_client: Optional httpx.AsyncClient for connection pooling in token verification. + When provided, the client is reused across verify_token calls and the caller + is responsible for its lifecycle. When None (default), a fresh client is created per call. + enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based + client IDs (default True). Set to False to disable. + """ + # Apply defaults and ensure authkit_domain is a full URL + authkit_domain_str = authkit_domain + if not authkit_domain_str.startswith(("http://", "https://")): + authkit_domain_str = f"https://{authkit_domain_str}" + authkit_domain_final = authkit_domain_str.rstrip("/") + scopes_final = ( + parse_scopes(required_scopes) if required_scopes is not None else [] + ) + + # Create WorkOS token verifier + token_verifier = WorkOSTokenVerifier( + authkit_domain=authkit_domain_final, + required_scopes=scopes_final, + timeout_seconds=timeout_seconds, + http_client=http_client, + ) + + # Initialize OAuth proxy with WorkOS AuthKit endpoints + super().__init__( + upstream_authorization_endpoint=f"{authkit_domain_final}/oauth2/authorize", + upstream_token_endpoint=f"{authkit_domain_final}/oauth2/token", + upstream_client_id=client_id, + upstream_client_secret=client_secret, + token_verifier=token_verifier, + base_url=base_url, + resource_base_url=resource_base_url, + redirect_path=redirect_path, + issuer_url=issuer_url or base_url, # Default to base_url if not specified + allowed_client_redirect_uris=allowed_client_redirect_uris, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, + forward_resource=forward_resource, + enable_cimd=enable_cimd, + ) + + logger.debug( + "Initialized WorkOS OAuth provider for client %s with AuthKit domain %s", + client_id, + authkit_domain_final, + ) diff --git a/src/fastmcp/server/plugins/base.py b/src/fastmcp/server/plugins/base.py index 8851e22b1..74fc8e63b 100644 --- a/src/fastmcp/server/plugins/base.py +++ b/src/fastmcp/server/plugins/base.py @@ -378,6 +378,9 @@ class Plugin(Generic[C]): def middleware(self): # self.config is typed as PIIRedactorConfig return [PIIMiddleware(self.config.patterns)] + + + plugin = PIIRedactor(PIIRedactor.Config(patterns=["email"])) ``` """ @@ -396,6 +399,17 @@ class Plugin(Generic[C]): for plugins that don't parameterize `Plugin`. """ + Config: ClassVar[type[BaseModel]] = _EmptyConfig + """Public alias for the plugin's config model. + + This lets users instantiate a plugin's config without importing the + implementation-specific config class separately: + + ```python + SomePlugin(SomePlugin.Config(...)) + ``` + """ + config: C """The validated config instance. Typed as `C`, the generic parameter, so `self.config.` type-checks correctly.""" @@ -419,6 +433,7 @@ class Plugin(Generic[C]): config_cls = _resolve_plugin_config_cls(cls) if config_cls is not None: cls._config_cls = config_cls + cls.Config = cls._config_cls # Enforce the JSON-serializable contract on the resolved config. # Every plugin config must round-trip through JSON so plugins # can be loaded from config files, rendered by registry/Horizon diff --git a/src/fastmcp/server/plugins/code_mode/plugin.py b/src/fastmcp/server/plugins/code_mode/plugin.py index a557aee7d..72142d6ba 100644 --- a/src/fastmcp/server/plugins/code_mode/plugin.py +++ b/src/fastmcp/server/plugins/code_mode/plugin.py @@ -10,7 +10,7 @@ for servers with many tools. from __future__ import annotations -from typing import Any, Literal +from typing import Any, ClassVar, Literal from pydantic import BaseModel, ConfigDict @@ -73,7 +73,6 @@ class CodeMode(Plugin[CodeModeConfig]): ```python from fastmcp.server.plugins.code_mode import ( CodeMode, - CodeModeConfig, GetSchemas, ListTools, ) @@ -82,7 +81,7 @@ class CodeMode(Plugin[CodeModeConfig]): "Server", plugins=[ CodeMode( - CodeModeConfig(execute_tool_name="run"), + CodeMode.Config(execute_tool_name="run"), sandbox_provider=my_custom_sandbox, discovery_tools=[ListTools(), GetSchemas()], ) @@ -91,6 +90,8 @@ class CodeMode(Plugin[CodeModeConfig]): ``` """ + Config: ClassVar[type[CodeModeConfig]] = CodeModeConfig + # `meta` is auto-derived (name="code-mode", version=None) — the right # answer for a bundled first-party plugin. Declare `meta` explicitly # (or use `PluginMeta.from_package(...)`) if published separately. diff --git a/src/fastmcp/server/plugins/openapi/__init__.py b/src/fastmcp/server/plugins/openapi/__init__.py index d9ef7e1ca..aacb93eda 100644 --- a/src/fastmcp/server/plugins/openapi/__init__.py +++ b/src/fastmcp/server/plugins/openapi/__init__.py @@ -1,11 +1,11 @@ """OpenAPI plugin — mount an OpenAPI spec as MCP tools/resources. from fastmcp import FastMCP - from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig + from fastmcp.server.plugins.openapi import OpenAPI mcp = FastMCP( "Petstore", - plugins=[OpenAPI(OpenAPIConfig(spec=petstore_spec))], + plugins=[OpenAPI(OpenAPI.Config(spec=petstore_spec))], ) Typed `RouteMap` + `MCPType` are re-exported for the Python-only diff --git a/src/fastmcp/server/plugins/openapi/plugin.py b/src/fastmcp/server/plugins/openapi/plugin.py index f1ca2227a..da6ccd177 100644 --- a/src/fastmcp/server/plugins/openapi/plugin.py +++ b/src/fastmcp/server/plugins/openapi/plugin.py @@ -14,7 +14,7 @@ from __future__ import annotations import json from pathlib import Path -from typing import Any, Literal +from typing import Any, ClassVar, Literal import httpx from pydantic import BaseModel, ConfigDict @@ -123,21 +123,21 @@ class OpenAPI(Plugin[OpenAPIConfig]): """Mount an OpenAPI spec as an MCP server via a plugin. Everything declarative (spec, base URL, headers, route mappings) - goes in `OpenAPIConfig`. Python-only knobs — custom `httpx.AsyncClient`, + goes in `OpenAPI.Config`. Python-only knobs — custom `httpx.AsyncClient`, route-mapping callables, component customization — go in `__init__` kwargs. Example: ```python from fastmcp import FastMCP - from fastmcp.server.plugins.openapi import OpenAPI, OpenAPIConfig + from fastmcp.server.plugins.openapi import OpenAPI # Declarative (JSON-friendly): mcp = FastMCP( "Petstore", plugins=[ OpenAPI( - OpenAPIConfig( + OpenAPI.Config( spec=petstore_spec, base_url="https://api.example.com", headers={"Authorization": "Bearer ..."}, @@ -152,7 +152,7 @@ class OpenAPI(Plugin[OpenAPIConfig]): "Petstore", plugins=[ OpenAPI( - OpenAPIConfig(spec=petstore_spec), + OpenAPI.Config(spec=petstore_spec), client=custom_client, ) ], @@ -160,6 +160,8 @@ class OpenAPI(Plugin[OpenAPIConfig]): ``` """ + Config: ClassVar[type[OpenAPIConfig]] = OpenAPIConfig + # "OpenAPI" is a single technical term; the auto-kebab would split # it into "open-api", which is uglier than the established spelling. meta = PluginMeta(name="openapi") diff --git a/src/fastmcp/server/plugins/prompts_as_tools/plugin.py b/src/fastmcp/server/plugins/prompts_as_tools/plugin.py index a4b192a4c..a853a5483 100644 --- a/src/fastmcp/server/plugins/prompts_as_tools/plugin.py +++ b/src/fastmcp/server/plugins/prompts_as_tools/plugin.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import ClassVar + from pydantic import BaseModel, ConfigDict from fastmcp.server.plugins.base import Plugin @@ -37,5 +39,7 @@ class PromptsAsTools(Plugin[PromptsAsToolsConfig]): ``` """ + Config: ClassVar[type[PromptsAsToolsConfig]] = PromptsAsToolsConfig + def transforms(self) -> list[Transform]: return [PromptsAsToolsTransform()] diff --git a/src/fastmcp/server/plugins/resources_as_tools/plugin.py b/src/fastmcp/server/plugins/resources_as_tools/plugin.py index c6948b793..add9df90f 100644 --- a/src/fastmcp/server/plugins/resources_as_tools/plugin.py +++ b/src/fastmcp/server/plugins/resources_as_tools/plugin.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import ClassVar + from pydantic import BaseModel, ConfigDict from fastmcp.server.plugins.base import Plugin @@ -39,5 +41,7 @@ class ResourcesAsTools(Plugin[ResourcesAsToolsConfig]): ``` """ + Config: ClassVar[type[ResourcesAsToolsConfig]] = ResourcesAsToolsConfig + def transforms(self) -> list[Transform]: return [ResourcesAsToolsTransform()] diff --git a/src/fastmcp/server/plugins/skills/__init__.py b/src/fastmcp/server/plugins/skills/__init__.py index 518507a31..7cfba05f3 100644 --- a/src/fastmcp/server/plugins/skills/__init__.py +++ b/src/fastmcp/server/plugins/skills/__init__.py @@ -1,9 +1,9 @@ """Skills plugin — expose agent skill folders as MCP resources. from fastmcp import FastMCP - from fastmcp.server.plugins.skills import Skills, SkillsConfig + from fastmcp.server.plugins.skills import Skills - mcp = FastMCP("skills", plugins=[Skills(SkillsConfig(vendor="claude"))]) + mcp = FastMCP("skills", plugins=[Skills(Skills.Config(vendor="claude"))]) The underlying `SkillProvider` and `SkillsDirectoryProvider` classes live on `.skill_provider` and `.directory_provider` submodules for diff --git a/src/fastmcp/server/plugins/skills/plugin.py b/src/fastmcp/server/plugins/skills/plugin.py index 3176d123a..cfbb93c29 100644 --- a/src/fastmcp/server/plugins/skills/plugin.py +++ b/src/fastmcp/server/plugins/skills/plugin.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Literal +from typing import Any, ClassVar, Literal from pydantic import BaseModel, ConfigDict @@ -14,7 +14,7 @@ from fastmcp.server.providers import Provider # Vendor-name → list of skill-root paths. Captures the same preset # paths the vendor subclasses (`ClaudeSkillsProvider`, `CursorSkillsProvider`, -# etc.) used to hardcode. The dict lets `Skills(SkillsConfig(vendor="claude"))` +# etc.) used to hardcode. The dict lets `Skills(Skills.Config(vendor="claude"))` # replace seven separate subclass names with one plugin + an enum value. VENDOR_PATHS: dict[str, list[Path]] = { "claude": [Path.home() / ".claude" / "skills"], @@ -93,28 +93,30 @@ class Skills(Plugin[SkillsConfig]): Example: ```python from fastmcp import FastMCP - from fastmcp.server.plugins.skills import Skills, SkillsConfig + from fastmcp.server.plugins.skills import Skills # Vendor preset — the common case: mcp = FastMCP( "skills", - plugins=[Skills(SkillsConfig(vendor="claude"))], + plugins=[Skills(Skills.Config(vendor="claude"))], ) # Custom directory: mcp = FastMCP( "skills", - plugins=[Skills(SkillsConfig(directory="./skills"))], + plugins=[Skills(Skills.Config(directory="./skills"))], ) # Single skill folder: mcp = FastMCP( "skills", - plugins=[Skills(SkillsConfig(path="./skills/pdf-processing"))], + plugins=[Skills(Skills.Config(path="./skills/pdf-processing"))], ) ``` """ + Config: ClassVar[type[SkillsConfig]] = SkillsConfig + def providers(self) -> list[Provider]: return [self._build_provider()] diff --git a/src/fastmcp/server/plugins/tool_search/plugin.py b/src/fastmcp/server/plugins/tool_search/plugin.py index cb1a08c69..936610512 100644 --- a/src/fastmcp/server/plugins/tool_search/plugin.py +++ b/src/fastmcp/server/plugins/tool_search/plugin.py @@ -8,7 +8,7 @@ user code should configure behavior through the plugin. from __future__ import annotations -from typing import Literal +from typing import ClassVar, Literal from pydantic import BaseModel, ConfigDict @@ -51,7 +51,7 @@ class ToolSearch(Plugin[ToolSearchConfig]): Example: ```python from fastmcp import FastMCP - from fastmcp.server.plugins.tool_search import ToolSearch, ToolSearchConfig + from fastmcp.server.plugins.tool_search import ToolSearch # Default config: mcp = FastMCP("Server", plugins=[ToolSearch()]) @@ -59,7 +59,7 @@ class ToolSearch(Plugin[ToolSearchConfig]): # Typed config (IDE completion + static validation): mcp = FastMCP( "Server", - plugins=[ToolSearch(ToolSearchConfig(strategy="regex", always_visible=["help"]))], + plugins=[ToolSearch(ToolSearch.Config(strategy="regex", always_visible=["help"]))], ) # Dict config (useful for loading from JSON/YAML): @@ -67,6 +67,8 @@ class ToolSearch(Plugin[ToolSearchConfig]): ``` """ + Config: ClassVar[type[ToolSearchConfig]] = ToolSearchConfig + # `meta` is intentionally omitted: the auto-derived default # (`name="tool-search"`, `version=None`) is appropriate for a # bundled first-party plugin with no independent release cadence. diff --git a/tests/deprecated/test_auth_provider_imports.py b/tests/deprecated/test_auth_provider_imports.py new file mode 100644 index 000000000..57d41fb32 --- /dev/null +++ b/tests/deprecated/test_auth_provider_imports.py @@ -0,0 +1,137 @@ +"""Test that deprecated auth provider import paths still work.""" + +from __future__ import annotations + +import importlib +import sys +import warnings + +import pytest + +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.utilities.tests import temporary_settings + +AUTH_PROVIDER_SHIMS = [ + ( + "auth0", + "fastmcp.server.plugins.auth.auth0.provider", + ("Auth0Provider",), + ), + ( + "aws", + "fastmcp.server.plugins.auth.aws.provider", + ("AWSCognitoProvider", "AWSCognitoTokenVerifier"), + ), + ( + "azure", + "fastmcp.server.plugins.auth.azure.provider", + ("AzureJWTVerifier", "AzureProvider", "EntraOBOToken"), + ), + ( + "clerk", + "fastmcp.server.plugins.auth.clerk.provider", + ("ClerkProvider", "ClerkTokenVerifier"), + ), + ( + "descope", + "fastmcp.server.plugins.auth.descope.provider", + ("DescopeProvider",), + ), + ( + "discord", + "fastmcp.server.plugins.auth.discord.provider", + ("DiscordProvider", "DiscordTokenVerifier"), + ), + ( + "github", + "fastmcp.server.plugins.auth.github.provider", + ("GitHubProvider", "GitHubTokenVerifier"), + ), + ( + "google", + "fastmcp.server.plugins.auth.google.provider", + ("GoogleProvider", "GoogleTokenVerifier"), + ), + ( + "keycloak", + "fastmcp.server.plugins.auth.keycloak.provider", + ("KeycloakAuthProvider",), + ), + ( + "oci", + "fastmcp.server.plugins.auth.oci.provider", + ("OCIProvider",), + ), + ( + "propelauth", + "fastmcp.server.plugins.auth.propelauth.provider", + ("PropelAuthProvider", "PropelAuthTokenIntrospectionOverrides"), + ), + ( + "scalekit", + "fastmcp.server.plugins.auth.scalekit.provider", + ("ScalekitProvider",), + ), + ( + "supabase", + "fastmcp.server.plugins.auth.supabase.provider", + ("SupabaseProvider",), + ), + ( + "workos", + "fastmcp.server.plugins.auth.workos.provider", + ("WorkOSProvider", "WorkOSTokenVerifier"), + ), + ( + "workos", + "fastmcp.server.plugins.auth.authkit.provider", + ("AuthKitProvider",), + ), +] + + +@pytest.mark.parametrize( + ("legacy_name", "canonical_module_name", "export_names"), + AUTH_PROVIDER_SHIMS, +) +def test_deprecated_auth_provider_imports_still_work( + legacy_name: str, + canonical_module_name: str, + export_names: tuple[str, ...], +): + legacy_module_name = f"fastmcp.server.auth.providers.{legacy_name}" + canonical_module = importlib.import_module(canonical_module_name) + + sys.modules.pop(legacy_module_name, None) + + with temporary_settings(deprecation_warnings=True): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + legacy_module = importlib.import_module(legacy_module_name) + + fastmcp_warns = [ + w for w in caught if issubclass(w.category, FastMCPDeprecationWarning) + ] + assert any("fastmcp.server.plugins.auth" in str(w.message) for w in fastmcp_warns) + + for export_name in export_names: + assert getattr(legacy_module, export_name) is getattr( + canonical_module, export_name + ) + + +@pytest.mark.parametrize( + "legacy_name", + sorted({legacy_name for legacy_name, _, _ in AUTH_PROVIDER_SHIMS}), +) +def test_deprecated_auth_provider_imports_are_silent_when_disabled( + legacy_name: str, +): + legacy_module_name = f"fastmcp.server.auth.providers.{legacy_name}" + + sys.modules.pop(legacy_module_name, None) + + with temporary_settings(deprecation_warnings=False): + with warnings.catch_warnings(): + warnings.simplefilter("error", FastMCPDeprecationWarning) + importlib.import_module(legacy_module_name) diff --git a/tests/integration_tests/auth/test_github_provider_integration.py b/tests/integration_tests/auth/test_github_provider_integration.py index 47d9c944a..8b742155c 100644 --- a/tests/integration_tests/auth/test_github_provider_integration.py +++ b/tests/integration_tests/auth/test_github_provider_integration.py @@ -24,7 +24,7 @@ from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy.models import ClientCode -from fastmcp.server.auth.providers.github import GitHubProvider +from fastmcp.server.plugins.auth.github.provider import GitHubProvider from fastmcp.utilities.tests import HeadlessOAuth, run_server_async FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID = os.getenv("FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID") diff --git a/tests/integration_tests/auth/test_keycloak_provider_integration.py b/tests/integration_tests/auth/test_keycloak_provider_integration.py index 3b3a8fafb..3bfca6ab4 100644 --- a/tests/integration_tests/auth/test_keycloak_provider_integration.py +++ b/tests/integration_tests/auth/test_keycloak_provider_integration.py @@ -7,7 +7,7 @@ import httpx import pytest from fastmcp import FastMCP -from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider +from fastmcp.server.plugins.auth.keycloak.provider import KeycloakAuthProvider TEST_REALM_URL = "https://keycloak.example.com/realms/test" TEST_BASE_URL = "https://fastmcp.example.com" diff --git a/tests/server/auth/providers/test_auth0.py b/tests/server/auth/providers/test_auth0.py index 2c8cb1b46..06220a550 100644 --- a/tests/server/auth/providers/test_auth0.py +++ b/tests/server/auth/providers/test_auth0.py @@ -5,8 +5,8 @@ from unittest.mock import patch import pytest from fastmcp.server.auth.oidc_proxy import OIDCConfiguration -from fastmcp.server.auth.providers.auth0 import Auth0Provider from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider TEST_CONFIG_URL = "https://example.com/.well-known/openid-configuration" TEST_CLIENT_ID = "test-client-id" diff --git a/tests/server/auth/providers/test_aws.py b/tests/server/auth/providers/test_aws.py index 4f5232b53..36764840a 100644 --- a/tests/server/auth/providers/test_aws.py +++ b/tests/server/auth/providers/test_aws.py @@ -3,7 +3,7 @@ from contextlib import contextmanager from unittest.mock import patch -from fastmcp.server.auth.providers.aws import ( +from fastmcp.server.plugins.auth.aws.provider import ( AWSCognitoProvider, ) diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 54b119ce9..b5ff43d19 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -8,8 +8,8 @@ from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl -from fastmcp.server.auth.providers.azure import AzureProvider from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair +from fastmcp.server.plugins.auth.azure.provider import AzureProvider @pytest.fixture diff --git a/tests/server/auth/providers/test_azure_scopes.py b/tests/server/auth/providers/test_azure_scopes.py index 8ab35431d..e17c357f5 100644 --- a/tests/server/auth/providers/test_azure_scopes.py +++ b/tests/server/auth/providers/test_azure_scopes.py @@ -4,13 +4,13 @@ import pytest from key_value.aio.stores.memory import MemoryStore from fastmcp.server.auth.auth import MultiAuth -from fastmcp.server.auth.providers.azure import ( +from fastmcp.server.auth.providers.jwt import RSAKeyPair, StaticTokenVerifier +from fastmcp.server.plugins.auth.azure.provider import ( OIDC_SCOPES, AzureJWTVerifier, AzureProvider, _find_azure_provider, ) -from fastmcp.server.auth.providers.jwt import RSAKeyPair, StaticTokenVerifier @pytest.fixture @@ -771,13 +771,16 @@ class TestAzureOBOIntegration: def test_entra_obo_token_is_importable(self): """Test that EntraOBOToken can be imported.""" - from fastmcp.server.auth.providers.azure import EntraOBOToken + from fastmcp.server.plugins.auth.azure.provider import EntraOBOToken assert EntraOBOToken is not None def test_entra_obo_token_creates_dependency(self): """Test that EntraOBOToken creates a dependency with scopes.""" - from fastmcp.server.auth.providers.azure import EntraOBOToken, _EntraOBOToken + from fastmcp.server.plugins.auth.azure.provider import ( + EntraOBOToken, + _EntraOBOToken, + ) dep = EntraOBOToken(["https://graph.microsoft.com/User.Read"]) assert isinstance(dep, _EntraOBOToken) @@ -786,7 +789,7 @@ class TestAzureOBOIntegration: def test_entra_obo_token_is_dependency_instance(self): """Test that EntraOBOToken is a Dependency instance.""" from fastmcp.dependencies import Dependency - from fastmcp.server.auth.providers.azure import _EntraOBOToken + from fastmcp.server.plugins.auth.azure.provider import _EntraOBOToken dep = _EntraOBOToken(["scope"]) assert isinstance(dep, Dependency) diff --git a/tests/server/auth/providers/test_clerk.py b/tests/server/auth/providers/test_clerk.py index 323b36572..3aae0dbfc 100644 --- a/tests/server/auth/providers/test_clerk.py +++ b/tests/server/auth/providers/test_clerk.py @@ -7,7 +7,7 @@ import pytest from key_value.aio.stores.memory import MemoryStore from pytest_httpx import HTTPXMock -from fastmcp.server.auth.providers.clerk import ClerkProvider, ClerkTokenVerifier +from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider, ClerkTokenVerifier CLERK_DOMAIN = "test-instance.clerk.accounts.dev" diff --git a/tests/server/auth/providers/test_descope.py b/tests/server/auth/providers/test_descope.py index 7dcfc477f..2a0a44bb9 100644 --- a/tests/server/auth/providers/test_descope.py +++ b/tests/server/auth/providers/test_descope.py @@ -8,8 +8,8 @@ import pytest from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport -from fastmcp.server.auth.providers.descope import DescopeProvider from fastmcp.server.auth.providers.jwt import JWTVerifier +from fastmcp.server.plugins.auth.descope.provider import DescopeProvider from fastmcp.utilities.tests import HeadlessOAuth, run_server_async diff --git a/tests/server/auth/providers/test_discord.py b/tests/server/auth/providers/test_discord.py index edf3ffdc7..244b09597 100644 --- a/tests/server/auth/providers/test_discord.py +++ b/tests/server/auth/providers/test_discord.py @@ -5,7 +5,10 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from key_value.aio.stores.memory import MemoryStore -from fastmcp.server.auth.providers.discord import DiscordProvider, DiscordTokenVerifier +from fastmcp.server.plugins.auth.discord.provider import ( + DiscordProvider, + DiscordTokenVerifier, +) @pytest.fixture @@ -120,7 +123,7 @@ class TestDiscordTokenVerifier: mock_client.get.return_value = token_info_response with patch( - "fastmcp.server.auth.providers.discord.httpx.AsyncClient" + "fastmcp.server.plugins.auth.discord.provider.httpx.AsyncClient" ) as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client result = await verifier.verify_token("token") diff --git a/tests/server/auth/providers/test_github.py b/tests/server/auth/providers/test_github.py index a0cc4b9cd..81fed9bf1 100644 --- a/tests/server/auth/providers/test_github.py +++ b/tests/server/auth/providers/test_github.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from key_value.aio.stores.memory import MemoryStore -from fastmcp.server.auth.providers.github import ( +from fastmcp.server.plugins.auth.github.provider import ( GitHubProvider, GitHubTokenVerifier, ) @@ -142,7 +142,7 @@ class TestGitHubTokenVerifier: # Patch the AsyncClient context manager with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_client_class: mock_client_class.return_value.__aenter__.return_value = mock_client @@ -203,7 +203,7 @@ class TestGitHubTokenVerifierCaching: mock_client = AsyncMock() with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_cls: mock_cls.return_value.__aenter__.return_value = mock_client @@ -226,7 +226,7 @@ class TestGitHubTokenVerifierCaching: mock_client = AsyncMock() with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_cls: mock_cls.return_value.__aenter__.return_value = mock_client @@ -244,7 +244,7 @@ class TestGitHubTokenVerifierCaching: mock_client = AsyncMock() with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_cls: mock_cls.return_value.__aenter__.return_value = mock_client @@ -265,7 +265,7 @@ class TestGitHubTokenVerifierCaching: mock_client = AsyncMock() with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_cls: mock_cls.return_value.__aenter__.return_value = mock_client @@ -299,7 +299,7 @@ class TestGitHubTokenVerifierCaching: scopes_response.headers = {} with patch( - "fastmcp.server.auth.providers.github.httpx.AsyncClient" + "fastmcp.server.plugins.auth.github.provider.httpx.AsyncClient" ) as mock_cls: mock_cls.return_value.__aenter__.return_value = mock_client diff --git a/tests/server/auth/providers/test_google.py b/tests/server/auth/providers/test_google.py index 229f04ad3..25d525e02 100644 --- a/tests/server/auth/providers/test_google.py +++ b/tests/server/auth/providers/test_google.py @@ -7,7 +7,7 @@ import pytest from key_value.aio.stores.memory import MemoryStore from pytest_httpx import HTTPXMock -from fastmcp.server.auth.providers.google import ( +from fastmcp.server.plugins.auth.google.provider import ( GOOGLE_SCOPE_ALIASES, GoogleProvider, GoogleTokenVerifier, diff --git a/tests/server/auth/providers/test_http_client.py b/tests/server/auth/providers/test_http_client.py index fa6126183..be9a3bfd7 100644 --- a/tests/server/auth/providers/test_http_client.py +++ b/tests/server/auth/providers/test_http_client.py @@ -212,14 +212,14 @@ class TestGitHubHttpClient: """Test http_client parameter on GitHubTokenVerifier.""" def test_stores_http_client(self): - from fastmcp.server.auth.providers.github import GitHubTokenVerifier + from fastmcp.server.plugins.auth.github.provider import GitHubTokenVerifier client = httpx.AsyncClient() verifier = GitHubTokenVerifier(http_client=client) assert verifier._http_client is client async def test_uses_provided_client(self, httpx_mock: HTTPXMock): - from fastmcp.server.auth.providers.github import GitHubTokenVerifier + from fastmcp.server.plugins.auth.github.provider import GitHubTokenVerifier client = httpx.AsyncClient() httpx_mock.add_response( @@ -242,7 +242,7 @@ class TestDiscordHttpClient: """Test http_client parameter on DiscordTokenVerifier.""" def test_stores_http_client(self): - from fastmcp.server.auth.providers.discord import DiscordTokenVerifier + from fastmcp.server.plugins.auth.discord.provider import DiscordTokenVerifier client = httpx.AsyncClient() verifier = DiscordTokenVerifier( @@ -256,7 +256,7 @@ class TestGoogleHttpClient: """Test http_client parameter on GoogleTokenVerifier.""" def test_stores_http_client(self): - from fastmcp.server.auth.providers.google import GoogleTokenVerifier + from fastmcp.server.plugins.auth.google.provider import GoogleTokenVerifier client = httpx.AsyncClient() verifier = GoogleTokenVerifier(http_client=client) @@ -267,7 +267,7 @@ class TestWorkOSHttpClient: """Test http_client parameter on WorkOSTokenVerifier.""" def test_stores_http_client(self): - from fastmcp.server.auth.providers.workos import WorkOSTokenVerifier + from fastmcp.server.plugins.auth.workos.provider import WorkOSTokenVerifier client = httpx.AsyncClient() verifier = WorkOSTokenVerifier( @@ -281,7 +281,7 @@ class TestProviderHttpClientPassthrough: """Test that convenience providers pass http_client to their verifiers.""" def test_github_provider_threads_http_client(self): - from fastmcp.server.auth.providers.github import ( + from fastmcp.server.plugins.auth.github.provider import ( GitHubProvider, GitHubTokenVerifier, ) @@ -299,7 +299,7 @@ class TestProviderHttpClientPassthrough: assert verifier._http_client is client def test_discord_provider_threads_http_client(self): - from fastmcp.server.auth.providers.discord import ( + from fastmcp.server.plugins.auth.discord.provider import ( DiscordProvider, DiscordTokenVerifier, ) @@ -316,7 +316,7 @@ class TestProviderHttpClientPassthrough: assert verifier._http_client is client def test_google_provider_threads_http_client(self): - from fastmcp.server.auth.providers.google import ( + from fastmcp.server.plugins.auth.google.provider import ( GoogleProvider, GoogleTokenVerifier, ) @@ -333,7 +333,7 @@ class TestProviderHttpClientPassthrough: assert verifier._http_client is client def test_workos_provider_threads_http_client(self): - from fastmcp.server.auth.providers.workos import ( + from fastmcp.server.plugins.auth.workos.provider import ( WorkOSProvider, WorkOSTokenVerifier, ) @@ -351,8 +351,8 @@ class TestProviderHttpClientPassthrough: assert verifier._http_client is client def test_azure_provider_threads_http_client(self): - from fastmcp.server.auth.providers.azure import AzureProvider from fastmcp.server.auth.providers.jwt import JWTVerifier + from fastmcp.server.plugins.auth.azure.provider import AzureProvider client = httpx.AsyncClient() provider = AzureProvider( diff --git a/tests/server/auth/providers/test_keycloak.py b/tests/server/auth/providers/test_keycloak.py index 4312e0103..97c43072e 100644 --- a/tests/server/auth/providers/test_keycloak.py +++ b/tests/server/auth/providers/test_keycloak.py @@ -3,7 +3,7 @@ import pytest from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider +from fastmcp.server.plugins.auth.keycloak.provider import KeycloakAuthProvider TEST_REALM_URL = "https://keycloak.example.com/realms/test" TEST_BASE_URL = "https://example.com:8000" diff --git a/tests/server/auth/providers/test_propelauth.py b/tests/server/auth/providers/test_propelauth.py index f06efe685..a614aa6ba 100644 --- a/tests/server/auth/providers/test_propelauth.py +++ b/tests/server/auth/providers/test_propelauth.py @@ -10,7 +10,7 @@ from pydantic import SecretStr from fastmcp import Client, FastMCP from fastmcp.server.auth import AccessToken from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier -from fastmcp.server.auth.providers.propelauth import ( +from fastmcp.server.plugins.auth.propelauth.provider import ( PropelAuthProvider, PropelAuthTokenIntrospectionOverrides, ) @@ -318,7 +318,7 @@ class TestPropelAuthProviderIntegration: real_httpx_client = httpx.AsyncClient monkeypatch.setattr( - "fastmcp.server.auth.providers.propelauth.httpx.AsyncClient", + "fastmcp.server.plugins.auth.propelauth.provider.httpx.AsyncClient", DummyAsyncClient, ) diff --git a/tests/server/auth/providers/test_scalekit.py b/tests/server/auth/providers/test_scalekit.py index a47840682..47e4dd597 100644 --- a/tests/server/auth/providers/test_scalekit.py +++ b/tests/server/auth/providers/test_scalekit.py @@ -6,7 +6,7 @@ import pytest from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.server.auth.providers.scalekit import ScalekitProvider +from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider from fastmcp.utilities.tests import HeadlessOAuth, run_server_async @@ -202,7 +202,7 @@ class TestScalekitProviderIntegration: real_httpx_client = httpx.AsyncClient monkeypatch.setattr( - "fastmcp.server.auth.providers.scalekit.httpx.AsyncClient", + "fastmcp.server.plugins.auth.scalekit.provider.httpx.AsyncClient", DummyAsyncClient, ) diff --git a/tests/server/auth/providers/test_supabase.py b/tests/server/auth/providers/test_supabase.py index 1537bff04..8a1a490e5 100644 --- a/tests/server/auth/providers/test_supabase.py +++ b/tests/server/auth/providers/test_supabase.py @@ -8,7 +8,7 @@ import pytest from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.server.auth.providers.supabase import SupabaseProvider +from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index 2e87956c1..06c027b08 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -10,8 +10,8 @@ from pytest_httpx import HTTPXMock from fastmcp import Client, FastMCP from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.server.auth.providers.workos import ( - AuthKitProvider, +from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider +from fastmcp.server.plugins.auth.workos.provider import ( WorkOSProvider, WorkOSTokenVerifier, ) diff --git a/tests/server/plugins/test_auth_plugins.py b/tests/server/plugins/test_auth_plugins.py index d208421fa..752febd75 100644 --- a/tests/server/plugins/test_auth_plugins.py +++ b/tests/server/plugins/test_auth_plugins.py @@ -10,53 +10,37 @@ from pydantic import ValidationError from fastmcp import FastMCP from fastmcp.server.auth.oidc_proxy import OIDCConfiguration -from fastmcp.server.auth.providers.auth0 import Auth0Provider -from fastmcp.server.auth.providers.aws import AWSCognitoProvider -from fastmcp.server.auth.providers.azure import AzureProvider -from fastmcp.server.auth.providers.clerk import ClerkProvider -from fastmcp.server.auth.providers.descope import DescopeProvider -from fastmcp.server.auth.providers.discord import DiscordProvider -from fastmcp.server.auth.providers.github import GitHubProvider -from fastmcp.server.auth.providers.google import GoogleProvider from fastmcp.server.auth.providers.jwt import StaticTokenVerifier -from fastmcp.server.auth.providers.keycloak import KeycloakAuthProvider -from fastmcp.server.auth.providers.oci import OCIProvider -from fastmcp.server.auth.providers.propelauth import PropelAuthProvider -from fastmcp.server.auth.providers.scalekit import ScalekitProvider -from fastmcp.server.auth.providers.supabase import SupabaseProvider -from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSProvider -from fastmcp.server.plugins.auth import ( - Auth0Auth, - Auth0AuthConfig, - AuthKitAuth, - AuthKitAuthConfig, - AWSCognitoAuth, - AWSCognitoAuthConfig, - AzureAuth, - AzureAuthConfig, - ClerkAuth, - ClerkAuthConfig, - DescopeAuth, - DescopeAuthConfig, - DiscordAuth, - DiscordAuthConfig, - GitHubAuth, - GitHubAuthConfig, - GoogleAuth, - GoogleAuthConfig, - KeycloakAuth, - KeycloakAuthConfig, - OCIAuth, - OCIAuthConfig, - PropelAuth, - PropelAuthConfig, - ScalekitAuth, - ScalekitAuthConfig, - SupabaseAuth, - SupabaseAuthConfig, - WorkOSAuth, - WorkOSAuthConfig, -) +from fastmcp.server.plugins.auth.auth0 import Auth0Auth +from fastmcp.server.plugins.auth.auth0.provider import Auth0Provider +from fastmcp.server.plugins.auth.authkit import AuthKitAuth +from fastmcp.server.plugins.auth.authkit.provider import AuthKitProvider +from fastmcp.server.plugins.auth.aws import AWSCognitoAuth +from fastmcp.server.plugins.auth.aws.provider import AWSCognitoProvider +from fastmcp.server.plugins.auth.azure import AzureAuth +from fastmcp.server.plugins.auth.azure.provider import AzureProvider +from fastmcp.server.plugins.auth.clerk import ClerkAuth +from fastmcp.server.plugins.auth.clerk.provider import ClerkProvider +from fastmcp.server.plugins.auth.descope import DescopeAuth +from fastmcp.server.plugins.auth.descope.provider import DescopeProvider +from fastmcp.server.plugins.auth.discord import DiscordAuth +from fastmcp.server.plugins.auth.discord.provider import DiscordProvider +from fastmcp.server.plugins.auth.github import GitHubAuth +from fastmcp.server.plugins.auth.github.provider import GitHubProvider +from fastmcp.server.plugins.auth.google import GoogleAuth +from fastmcp.server.plugins.auth.google.provider import GoogleProvider +from fastmcp.server.plugins.auth.keycloak import KeycloakAuth +from fastmcp.server.plugins.auth.keycloak.provider import KeycloakAuthProvider +from fastmcp.server.plugins.auth.oci import OCIAuth +from fastmcp.server.plugins.auth.oci.provider import OCIProvider +from fastmcp.server.plugins.auth.propelauth import PropelAuth +from fastmcp.server.plugins.auth.propelauth.provider import PropelAuthProvider +from fastmcp.server.plugins.auth.scalekit import ScalekitAuth +from fastmcp.server.plugins.auth.scalekit.provider import ScalekitProvider +from fastmcp.server.plugins.auth.supabase import SupabaseAuth +from fastmcp.server.plugins.auth.supabase.provider import SupabaseProvider +from fastmcp.server.plugins.auth.workos import WorkOSAuth +from fastmcp.server.plugins.auth.workos.provider import WorkOSProvider def _verifier() -> StaticTokenVerifier: @@ -89,7 +73,7 @@ def _mock_oidc_discovery(): PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ( Auth0Auth, - Auth0AuthConfig, + Auth0Auth.Config, { "config_url": "https://idp.example.com/.well-known/openid-configuration", "client_id": "client", @@ -101,7 +85,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( AuthKitAuth, - AuthKitAuthConfig, + AuthKitAuth.Config, { "authkit_domain": "https://example.authkit.app", "base_url": "https://mcp.example.com", @@ -110,7 +94,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( AWSCognitoAuth, - AWSCognitoAuthConfig, + AWSCognitoAuth.Config, { "user_pool_id": "us-east-1_abc", "client_id": "client", @@ -122,7 +106,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( AzureAuth, - AzureAuthConfig, + AzureAuth.Config, { "client_id": "client", "client_secret": "secret", @@ -134,7 +118,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( ClerkAuth, - ClerkAuthConfig, + ClerkAuth.Config, { "domain": "example.clerk.accounts.dev", "client_id": "client", @@ -145,7 +129,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( DescopeAuth, - DescopeAuthConfig, + DescopeAuth.Config, { "config_url": "https://api.descope.com/v1/apps/agentic/P123/M456/.well-known/openid-configuration", "base_url": "https://mcp.example.com", @@ -154,7 +138,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( DiscordAuth, - DiscordAuthConfig, + DiscordAuth.Config, { "client_id": "client", "client_secret": "secret", @@ -164,7 +148,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( GitHubAuth, - GitHubAuthConfig, + GitHubAuth.Config, { "client_id": "client", "client_secret": "secret", @@ -174,7 +158,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( GoogleAuth, - GoogleAuthConfig, + GoogleAuth.Config, { "client_id": "client", "client_secret": "secret", @@ -184,7 +168,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( KeycloakAuth, - KeycloakAuthConfig, + KeycloakAuth.Config, { "realm_url": "https://keycloak.example.com/realms/main", "base_url": "https://mcp.example.com", @@ -193,7 +177,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( OCIAuth, - OCIAuthConfig, + OCIAuth.Config, { "config_url": "https://idp.example.com/.well-known/openid-configuration", "client_id": "client", @@ -204,7 +188,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( PropelAuth, - PropelAuthConfig, + PropelAuth.Config, { "auth_url": "https://auth.example.com", "introspection_client_id": "client", @@ -215,7 +199,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( ScalekitAuth, - ScalekitAuthConfig, + ScalekitAuth.Config, { "environment_url": "https://env.scalekit.com", "resource_id": "res_123", @@ -225,7 +209,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( SupabaseAuth, - SupabaseAuthConfig, + SupabaseAuth.Config, { "project_url": "https://abc123.supabase.co", "base_url": "https://mcp.example.com", @@ -234,7 +218,7 @@ PROVIDER_CASES: list[tuple[type, type, dict[str, Any], type]] = [ ), ( WorkOSAuth, - WorkOSAuthConfig, + WorkOSAuth.Config, { "client_id": "client", "client_secret": "secret", @@ -264,6 +248,7 @@ class TestAuthProviderPlugins: ) def test_config_generic_binding(self, plugin_cls, config_cls, config, provider_cls): assert plugin_cls._config_cls is config_cls + assert plugin_cls.Config is config_cls @pytest.mark.parametrize( ("plugin_cls", "config_cls", "config", "provider_cls"), PROVIDER_CASES @@ -318,7 +303,7 @@ class TestAuthProviderPlugins: def test_supabase_passthroughs_config_and_python_verifier(self): verifier = _verifier() plugin = SupabaseAuth( - SupabaseAuthConfig( + SupabaseAuth.Config( project_url="https://abc123.supabase.co", base_url="https://mcp.example.com", required_scopes=["read"], diff --git a/tests/server/plugins/test_code_mode_plugin.py b/tests/server/plugins/test_code_mode_plugin.py index b06ba869f..5790a676d 100644 --- a/tests/server/plugins/test_code_mode_plugin.py +++ b/tests/server/plugins/test_code_mode_plugin.py @@ -34,6 +34,7 @@ class TestCodeModeConfig: def test_config_generic_binding(self): """`Plugin[CodeModeConfig]` binds CodeModeConfig as the validated config type.""" assert CodeMode._config_cls is CodeModeConfig + assert CodeMode.Config is CodeModeConfig def test_dict_config_accepted(self): """Dict config works for loading from JSON/YAML.""" @@ -42,11 +43,11 @@ class TestCodeModeConfig: def test_unknown_sandbox_rejected(self): with pytest.raises((ValidationError, Exception), match="sandbox"): - CodeModeConfig(sandbox="docker") # ty: ignore[invalid-argument-type] + CodeMode.Config(sandbox="docker") # ty: ignore[invalid-argument-type] def test_unknown_config_key_rejected(self): with pytest.raises((ValidationError, Exception), match="forbid|extra"): - CodeModeConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + CodeMode.Config(not_a_real_option=True) # ty: ignore[unknown-argument] def test_default_meta(self): """CodeMode uses Plugin's auto-derived meta: kebab-cased class diff --git a/tests/server/plugins/test_openapi_plugin.py b/tests/server/plugins/test_openapi_plugin.py index c07c36e3b..c2ab6ff3c 100644 --- a/tests/server/plugins/test_openapi_plugin.py +++ b/tests/server/plugins/test_openapi_plugin.py @@ -56,16 +56,17 @@ PETSTORE_SPEC: dict = { class TestOpenAPIConfig: def test_config_generic_binding(self): assert OpenAPI._config_cls is OpenAPIConfig + assert OpenAPI.Config is OpenAPIConfig def test_default_config_instantiable(self): """Defaults must pass the plugin framework's instantiate-with-no-args contract. The spec/spec_path check fires at providers() time, not at Config construction.""" - assert OpenAPIConfig() # must not raise + assert OpenAPI.Config() # must not raise def test_unknown_config_key_rejected(self): with pytest.raises((ValidationError, Exception), match="forbid|extra"): - OpenAPIConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + OpenAPI.Config(not_a_real_option=True) # ty: ignore[unknown-argument] def test_meta_name_is_single_word(self): """'openapi' is one technical term — explicit meta override @@ -76,7 +77,7 @@ class TestOpenAPIConfig: class TestSpecLoading: async def test_inline_spec_builds_provider(self): - plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC)) + plugin = OpenAPI(OpenAPI.Config(spec=PETSTORE_SPEC)) mcp = FastMCP("petstore", plugins=[plugin]) async with Client(mcp) as c: @@ -89,7 +90,7 @@ class TestSpecLoading: spec_file = tmp_path / "petstore.json" spec_file.write_text(json.dumps(PETSTORE_SPEC)) - plugin = OpenAPI(OpenAPIConfig(spec_path=str(spec_file))) + plugin = OpenAPI(OpenAPI.Config(spec_path=str(spec_file))) mcp = FastMCP("petstore", plugins=[plugin]) async with Client(mcp) as c: @@ -113,12 +114,12 @@ class TestSpecLoading: encoding="utf-8", ) - plugin = OpenAPI(OpenAPIConfig(spec_path=str(spec_file))) + plugin = OpenAPI(OpenAPI.Config(spec_path=str(spec_file))) providers = plugin.providers() assert isinstance(providers[0], OpenAPIProvider) def test_missing_spec_fails_at_build_time(self): - plugin = OpenAPI(OpenAPIConfig()) + plugin = OpenAPI(OpenAPI.Config()) with pytest.raises(ValueError, match="spec.*spec_path"): plugin.providers() @@ -126,7 +127,7 @@ class TestSpecLoading: spec_file = tmp_path / "spec.json" spec_file.write_text(json.dumps(PETSTORE_SPEC)) - plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC, spec_path=str(spec_file))) + plugin = OpenAPI(OpenAPI.Config(spec=PETSTORE_SPEC, spec_path=str(spec_file))) with pytest.raises(ValueError, match="exactly one"): plugin.providers() @@ -134,7 +135,7 @@ class TestSpecLoading: class TestRouteMapping: def test_route_maps_dict_form_converts_to_typed(self): plugin = OpenAPI( - OpenAPIConfig( + OpenAPI.Config( spec=PETSTORE_SPEC, route_maps=[ RouteMapDict( @@ -149,7 +150,7 @@ class TestRouteMapping: async def test_list_pets_maps_to_resource_via_config(self): plugin = OpenAPI( - OpenAPIConfig( + OpenAPI.Config( spec=PETSTORE_SPEC, route_maps=[ RouteMapDict( @@ -172,7 +173,7 @@ class TestRouteMapping: the dict form in Config — advanced users shouldn't be shadowed by an empty default.""" plugin = OpenAPI( - OpenAPIConfig(spec=PETSTORE_SPEC), + OpenAPI.Config(spec=PETSTORE_SPEC), route_maps=[RouteMap(mcp_type=MCPType.EXCLUDE, pattern=r".*")], ) providers = plugin.providers() @@ -186,7 +187,7 @@ class TestDefaultClient: """When the plugin builds its own httpx client (user didn't pass `client=`), the provider's lifespan must still close it on shutdown. A leaked client was bug noted on PR #4015.""" - plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC)) + plugin = OpenAPI(OpenAPI.Config(spec=PETSTORE_SPEC)) provider = plugin.providers()[0] assert isinstance(provider, OpenAPIProvider) client = provider._client @@ -210,7 +211,7 @@ class TestDefaultClient: } ], } - plugin = OpenAPI(OpenAPIConfig(spec=templated_spec)) + plugin = OpenAPI(OpenAPI.Config(spec=templated_spec)) provider = plugin.providers()[0] assert isinstance(provider, OpenAPIProvider) assert str(provider._client.base_url) == "https://us-east.api.example.com" @@ -220,7 +221,7 @@ class TestEscapeHatches: async def test_custom_client_is_used(self): """Passing `client=` bypasses the auto-derived httpx client.""" client = httpx.AsyncClient(base_url="https://override.example.com") - plugin = OpenAPI(OpenAPIConfig(spec=PETSTORE_SPEC), client=client) + plugin = OpenAPI(OpenAPI.Config(spec=PETSTORE_SPEC), client=client) providers = plugin.providers() assert isinstance(providers[0], OpenAPIProvider) # Access the provider's client through the known private attr. diff --git a/tests/server/plugins/test_skills_plugin.py b/tests/server/plugins/test_skills_plugin.py index c3a82b4dd..20d35e8be 100644 --- a/tests/server/plugins/test_skills_plugin.py +++ b/tests/server/plugins/test_skills_plugin.py @@ -24,15 +24,16 @@ from fastmcp.server.plugins.skills.skill_provider import SkillProvider class TestSkillsConfig: def test_config_generic_binding(self): assert Skills._config_cls is SkillsConfig + assert Skills.Config is SkillsConfig def test_default_config_instantiable(self): """Defaults must pass the plugin framework's instantiate-with-no-args contract; the source check fires at providers() time.""" - assert SkillsConfig() # must not raise + assert Skills.Config() # must not raise def test_unknown_config_key_rejected(self): with pytest.raises((ValidationError, Exception), match="forbid|extra"): - SkillsConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + Skills.Config(not_a_real_option=True) # ty: ignore[unknown-argument] def test_default_meta(self): assert Skills.meta.name == "skills" @@ -45,12 +46,12 @@ class TestSourceResolution: skill.mkdir() (skill / "SKILL.md").write_text("# My Skill") - plugin = Skills(SkillsConfig(path=str(skill))) + plugin = Skills(Skills.Config(path=str(skill))) providers = plugin.providers() assert isinstance(providers[0], SkillProvider) def test_directory_source_builds_directory_provider(self, tmp_path: Path): - plugin = Skills(SkillsConfig(directory=str(tmp_path))) + plugin = Skills(Skills.Config(directory=str(tmp_path))) providers = plugin.providers() assert isinstance(providers[0], SkillsDirectoryProvider) @@ -58,7 +59,7 @@ class TestSourceResolution: a, b = tmp_path / "a", tmp_path / "b" a.mkdir() b.mkdir() - plugin = Skills(SkillsConfig(directory=[str(a), str(b)])) + plugin = Skills(Skills.Config(directory=[str(a), str(b)])) providers = plugin.providers() assert isinstance(providers[0], SkillsDirectoryProvider) @@ -66,17 +67,17 @@ class TestSourceResolution: def test_vendor_presets_resolve_to_known_paths(self, vendor: str): """Every vendor string must produce a directory provider rooted at the paths the old vendor subclass used to hardcode.""" - plugin = Skills(SkillsConfig(vendor=cast(Vendor, vendor))) + plugin = Skills(Skills.Config(vendor=cast(Vendor, vendor))) providers = plugin.providers() assert isinstance(providers[0], SkillsDirectoryProvider) def test_no_source_fails_at_build_time(self): - plugin = Skills(SkillsConfig()) + plugin = Skills(Skills.Config()) with pytest.raises(ValueError, match="path.*directory.*vendor"): plugin.providers() def test_multiple_sources_rejected(self, tmp_path: Path): - plugin = Skills(SkillsConfig(directory=str(tmp_path), vendor="claude")) + plugin = Skills(Skills.Config(directory=str(tmp_path), vendor="claude")) with pytest.raises(ValueError, match="exactly one"): plugin.providers() diff --git a/tests/server/plugins/test_tool_search.py b/tests/server/plugins/test_tool_search.py index f2a2990fa..193f7e59e 100644 --- a/tests/server/plugins/test_tool_search.py +++ b/tests/server/plugins/test_tool_search.py @@ -52,20 +52,20 @@ class TestSearchPluginRegistration: assert names == {"search_tools", "call_tool"} async def test_regex_strategy_dispatches_regex_transform(self): - plugin = ToolSearch(ToolSearchConfig(strategy="regex")) + plugin = ToolSearch(ToolSearch.Config(strategy="regex")) transforms = plugin.transforms() assert len(transforms) == 1 assert isinstance(transforms[0], RegexSearchTransform) async def test_bm25_strategy_dispatches_bm25_transform(self): - plugin = ToolSearch(ToolSearchConfig(strategy="bm25")) + plugin = ToolSearch(ToolSearch.Config(strategy="bm25")) transforms = plugin.transforms() assert len(transforms) == 1 assert isinstance(transforms[0], BM25SearchTransform) async def test_always_visible_pins_tools_alongside_search_call(self): mcp = _make_server_with_tools( - [ToolSearch(ToolSearchConfig(always_visible=["add"]))] + [ToolSearch(ToolSearch.Config(always_visible=["add"]))] ) async with Client(mcp) as c: @@ -78,7 +78,7 @@ class TestSearchPluginRegistration: mcp = _make_server_with_tools( [ ToolSearch( - ToolSearchConfig(search_tool_name="find", call_tool_name="invoke") + ToolSearch.Config(search_tool_name="find", call_tool_name="invoke") ) ] ) @@ -100,6 +100,7 @@ class TestSearchPluginRegistration: async def test_search_binds_searchconfig_via_generic_parameter(self): """`Plugin[ToolSearchConfig]` makes ToolSearchConfig the validated config type.""" assert ToolSearch._config_cls is ToolSearchConfig + assert ToolSearch.Config is ToolSearchConfig async def test_dict_config_still_accepted(self): """Dict config path (inherited from Plugin base) constructs cleanly — @@ -119,11 +120,11 @@ class TestSearchPluginRegistration: class TestSearchPluginConfigValidation: def test_unknown_strategy_rejected(self): with pytest.raises((ValidationError, Exception), match="strategy"): - ToolSearchConfig(strategy="fuzzy") # ty: ignore[invalid-argument-type] + ToolSearch.Config(strategy="fuzzy") # ty: ignore[invalid-argument-type] def test_unknown_config_key_rejected(self): with pytest.raises((ValidationError, Exception), match="forbid|extra"): - ToolSearchConfig(not_a_real_option=True) # ty: ignore[unknown-argument] + ToolSearch.Config(not_a_real_option=True) # ty: ignore[unknown-argument] def test_default_meta_name_and_version(self): """ToolSearch relies on Plugin's auto-derived meta: kebab-cased diff --git a/tests/server/test_plugins.py b/tests/server/test_plugins.py index 7ffdb17a9..f53ef4d82 100644 --- a/tests/server/test_plugins.py +++ b/tests/server/test_plugins.py @@ -344,6 +344,7 @@ class TestPluginConstruction: meta = PluginMeta(name="p", version="0.1.0") assert P._config_cls is PConfig + assert P.Config is PConfig def test_unparameterized_plugin_uses_empty_default_config(self): """A Plugin without a generic parameter gets an empty default that