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