Make PluginMeta.version optional; bundled plugins default to None (#3991)

This commit is contained in:
Jeremiah Lowin 2026-04-20 14:40:25 -04:00 committed by GitHub
commit 34cb2218dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 32 additions and 13 deletions

View file

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

View file

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