fastmcp/tests/cli/test_plugin_cli.py
2026-04-18 19:10:03 -04:00

95 lines
3.2 KiB
Python

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