mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-10 07:39:10 +02:00
1756 lines
62 KiB
Python
1756 lines
62 KiB
Python
"""Tests for the FastMCP plugin primitive."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from contextlib import asynccontextmanager, 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 pydantic
|
|
import pytest
|
|
from packaging.version import Version
|
|
from pydantic import BaseModel, ValidationError
|
|
|
|
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 _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."""
|
|
|
|
def test_required_fields(self):
|
|
meta = PluginMeta(name="x")
|
|
assert meta.name == "x"
|
|
# `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 == []
|
|
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"
|
|
|
|
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."""
|
|
|
|
# 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."""
|
|
|
|
def test_plugin_without_meta_auto_derives_from_class_name(self):
|
|
class ChannelPlugin(Plugin):
|
|
pass
|
|
|
|
p = ChannelPlugin()
|
|
# Class name is kebab-cased and the trailing "Plugin" suffix stripped.
|
|
assert p.meta.name == "channel"
|
|
# Bundled plugins have no independent version.
|
|
assert p.meta.version is None
|
|
|
|
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):
|
|
"""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, BaseModel)
|
|
# No fields to inspect — the point is that construction works with None.
|
|
|
|
def test_config_accepts_instance(self):
|
|
class PConfig(BaseModel):
|
|
who: str = "world"
|
|
|
|
class P(Plugin[PConfig]):
|
|
meta = PluginMeta(name="p", version="0.1.0")
|
|
|
|
p = P(PConfig(who="jeremiah"))
|
|
assert isinstance(p.config, PConfig)
|
|
assert p.config.who == "jeremiah"
|
|
|
|
def test_config_accepts_dict(self):
|
|
class PConfig(BaseModel):
|
|
who: str = "world"
|
|
|
|
class P(Plugin[PConfig]):
|
|
meta = PluginMeta(name="p", version="0.1.0")
|
|
|
|
p = P({"who": "jeremiah"})
|
|
assert isinstance(p.config, PConfig)
|
|
assert p.config.who == "jeremiah"
|
|
|
|
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")
|
|
|
|
# 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)
|
|
|
|
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("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:
|
|
"""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 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."""
|
|
|
|
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_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 ChildA(Plugin):
|
|
meta = PluginMeta(name="child-a", version="0.1.0")
|
|
|
|
async def setup(self, server):
|
|
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")
|
|
|
|
def on_install(self, server):
|
|
server.add_plugin(ChildA())
|
|
server.add_plugin(ChildB())
|
|
|
|
mcp = FastMCP("t", plugins=[Loader()])
|
|
# Children are registered at construction; no lifespan needed.
|
|
assert [p.meta.name for p in mcp.plugins] == [
|
|
"loader",
|
|
"child-a",
|
|
"child-b",
|
|
]
|
|
|
|
async with Client(mcp) as c:
|
|
await c.ping()
|
|
|
|
# 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
|
|
|
|
def test_loader_child_contributions_install_during_on_install(self):
|
|
"""Children registered by on_install install immediately.
|
|
|
|
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.
|
|
"""
|
|
|
|
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")
|
|
|
|
p = P()
|
|
mcp = FastMCP("t")
|
|
mcp.add_plugin(p)
|
|
|
|
with pytest.raises(PluginError, match="already installed"):
|
|
mcp.add_plugin(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):
|
|
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
|
|
|
|
def test_add_plugin_raises_when_later_hook_raises(self):
|
|
"""Contribution hook errors fail loudly.
|
|
|
|
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")
|
|
|
|
def middleware(self):
|
|
return [_TraceMiddleware("flaky")]
|
|
|
|
def transforms(self):
|
|
raise RuntimeError("transforms exploded")
|
|
|
|
mcp = FastMCP("t")
|
|
|
|
flaky = Flaky()
|
|
with pytest.raises(RuntimeError, match="transforms exploded"):
|
|
mcp.add_plugin(flaky)
|
|
|
|
assert flaky in mcp.plugins
|
|
assert flaky._installed_on is mcp
|
|
|
|
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")
|
|
|
|
def routes(self):
|
|
raise RuntimeError("routes exploded")
|
|
|
|
mcp = FastMCP("t")
|
|
plugin = RoutesBoom()
|
|
|
|
with pytest.raises(RuntimeError, match="routes exploded"):
|
|
mcp.add_plugin(plugin)
|
|
|
|
assert plugin in mcp.plugins
|
|
assert plugin._installed_on is mcp
|
|
|
|
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")
|
|
|
|
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())
|
|
|
|
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()
|
|
|
|
tags = [m.tag for m in mcp.middleware if isinstance(m, _TraceMiddleware)]
|
|
assert tags == ["child"]
|
|
|
|
|
|
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."""
|
|
|
|
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."""
|
|
|
|
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."""
|
|
|
|
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."""
|
|
|
|
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."""
|
|
|
|
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."""
|
|
|
|
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 PConfig(BaseModel):
|
|
who: str = "world"
|
|
|
|
class P(Plugin[PConfig]):
|
|
meta = PluginMeta(
|
|
name="p",
|
|
version="0.1.0",
|
|
description="demo",
|
|
tags=["x"],
|
|
dependencies=["demo>=0.1"],
|
|
fastmcp_version=">=3.0",
|
|
meta={"owning_team": "platform"},
|
|
)
|
|
|
|
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_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
|
|
|
|
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()
|
|
|
|
|
|
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_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."""
|
|
|
|
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_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):
|
|
def on_install(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") == {}
|
|
|
|
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"}
|