From a1ce39cf8a35f87456c1cd4cb2d9a3270e5a7a63 Mon Sep 17 00:00:00 2001 From: heznpc Date: Mon, 22 Jun 2026 18:04:58 +0900 Subject: [PATCH] harden python mcp server starter --- .github/workflows/cd.yml | 3 +- .github/workflows/ci.yml | 12 ++-- README.ko.md | 38 +++++++++--- README.md | 38 +++++++++--- pyproject.toml | 15 ++++- src/my_mcp_server/prompts/code_review.py | 4 +- src/my_mcp_server/server.py | 39 ++++++++---- tests/test_runtime_contract.py | 75 ++++++++++++++++++++++++ tests/test_tools.py | 12 ++++ 9 files changed, 198 insertions(+), 38 deletions(-) create mode 100644 tests/test_runtime_contract.py diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 279f29d..d7c1441 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -51,7 +51,8 @@ jobs: - name: Build run: | - pip install build + python -m pip install --upgrade pip + python -m pip install build python -m build - name: Generate SLSA build provenance attestation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10e2aae..fad8ef2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,9 @@ jobs: cache-dependency-path: pyproject.toml - name: Install dependencies - run: pip install -e . pip-licenses + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" - name: Check licenses run: pip-licenses --fail-on="GPL-2.0;GPL-3.0;AGPL-3.0" @@ -72,12 +74,12 @@ jobs: cache-dependency-path: pyproject.toml - name: Install dependencies - run: pip install -e ".[dev]" + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" - name: Dependency audit (pip-audit) - run: | - python -m pip install pip-audit - pip-audit --strict || pip-audit + run: pip-audit . --strict - name: Lint run: ruff check . diff --git a/README.ko.md b/README.ko.md index 130e30b..9ef8a4c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -39,7 +39,8 @@ MCP 서버를 만들고, 원클릭 배포. 시크릿 불필요. npx @starter-series/create my-mcp-server --template mcp-server-python cd my-mcp-server python -m venv .venv && source .venv/bin/activate -pip install -e '.[dev]' +python -m pip install -e '.[dev]' +python -m pytest ``` **또는 직접 클론:** @@ -48,7 +49,8 @@ pip install -e '.[dev]' git clone https://github.com/starter-series/python-mcp-server-starter my-mcp-server cd my-mcp-server python -m venv .venv && source .venv/bin/activate -pip install -e '.[dev]' +python -m pip install -e '.[dev]' +python -m pytest ``` ## 도구 추가 @@ -137,13 +139,25 @@ def register(mcp: FastMCP) -> None: ```bash # 테스트 실행 -pytest -v +python -m pytest -v # 린트 -ruff check . +python -m ruff check . + +# 포맷 확인 +python -m ruff format --check . + +# 타입 확인 +python -m mypy src/ + +# wheel + sdist 빌드 +python -m build # 서버 실행 (stdio) python -m my_mcp_server + +# 설치된 console script로 동일 서버 실행 +my-mcp-server ``` ## CI/CD @@ -182,11 +196,14 @@ src/my_mcp_server/ tests/ ├── test_tools.py # 툴 테스트 ├── test_server_info.py # Resource 테스트 -└── test_code_review.py # Prompt 테스트 +├── test_code_review.py # Prompt 테스트 +├── test_runtime_contract.py # 시작/패키지 메타데이터 테스트 +└── test_version_resolution.py # 버전 SSOT 테스트 .github/ ├── workflows/ │ ├── ci.yml # 린트, 테스트, 보안 │ ├── cd.yml # PyPI OIDC 배포 +│ ├── codeql.yml # 정적 분석 │ ├── stale.yml # Stale 이슈 관리 │ └── maintenance.yml # 주간 헬스 체크 └── dependabot.yml # 의존성 업데이트 @@ -195,11 +212,14 @@ tests/ ## 스크립트 ```bash -pip install -e ".[dev]" # dev 의존성 포함 설치 +python -m pip install -e ".[dev]" # dev 의존성 포함 설치 python -m my_mcp_server # 서버 실행 -pytest -v # 테스트 실행 -ruff check . # 린트 -ruff format . # 포맷 +my-mcp-server # 설치된 console script로 서버 실행 +python -m pytest -v # 테스트 실행 +python -m ruff check . # 린트 +python -m ruff format . # 포맷 +python -m mypy src/ # 타입 확인 +python -m build # wheel + sdist 빌드 ``` ## 라이선스 diff --git a/README.md b/README.md index 7a9bde7..965f114 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ Build your MCP server. One-click publish. Zero secrets needed. npx @starter-series/create my-mcp-server --template mcp-server-python cd my-mcp-server python -m venv .venv && source .venv/bin/activate -pip install -e '.[dev]' +python -m pip install -e '.[dev]' +python -m pytest ``` **Or clone directly:** @@ -51,7 +52,8 @@ pip install -e '.[dev]' git clone https://github.com/starter-series/python-mcp-server-starter my-mcp-server cd my-mcp-server python -m venv .venv && source .venv/bin/activate -pip install -e '.[dev]' +python -m pip install -e '.[dev]' +python -m pytest ``` ## Adding Tools @@ -157,13 +159,25 @@ Add your own in `server.py`. ```bash # Run tests -pytest -v +python -m pytest -v # Lint -ruff check . +python -m ruff check . + +# Format check +python -m ruff format --check . + +# Type check +python -m mypy src/ + +# Build wheel + sdist +python -m build # Run the server (stdio) python -m my_mcp_server + +# Same server via installed console script +my-mcp-server ``` ## CI/CD @@ -202,11 +216,14 @@ src/my_mcp_server/ tests/ ├── test_tools.py # Tool tests ├── test_server_info.py # Resource tests -└── test_code_review.py # Prompt tests +├── test_code_review.py # Prompt tests +├── test_runtime_contract.py # Startup + package metadata tests +└── test_version_resolution.py # Version SSOT tests .github/ ├── workflows/ │ ├── ci.yml # Lint, test, security │ ├── cd.yml # PyPI OIDC publish +│ ├── codeql.yml # Static analysis │ ├── stale.yml # Stale issue management │ └── maintenance.yml # Weekly health check └── dependabot.yml # Dependency updates @@ -215,11 +232,14 @@ tests/ ## Scripts ```bash -pip install -e ".[dev]" # Install with dev deps +python -m pip install -e ".[dev]" # Install with dev deps python -m my_mcp_server # Run server -pytest -v # Run tests -ruff check . # Lint -ruff format . # Format +my-mcp-server # Run installed console script +python -m pytest -v # Run tests +python -m ruff check . # Lint +python -m ruff format . # Format +python -m mypy src/ # Type check +python -m build # Build wheel + sdist ``` ## License diff --git a/pyproject.toml b/pyproject.toml index 62de486..b802df0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" license = "MIT" requires-python = ">=3.11" authors = [ - { name = "Your Name" }, + { name = "Heznpc" }, ] keywords = ["mcp", "mcp-server", "model-context-protocol"] classifiers = [ @@ -25,12 +25,20 @@ dependencies = [ "mcp>=1.27.1,<2", ] +[project.urls] +Homepage = "https://github.com/starter-series/python-mcp-server-starter" +Repository = "https://github.com/starter-series/python-mcp-server-starter" +Issues = "https://github.com/starter-series/python-mcp-server-starter/issues" + [project.scripts] -my-mcp-server = "my_mcp_server.server:main" +my-mcp-server = "my_mcp_server.__main__:run" [project.optional-dependencies] # Major-bounded so a breaking ruff/mypy/pytest release can't turn CI red overnight. dev = [ + "build>=1.5.0,<2", + "pip-audit>=2.10.1,<3", + "pip-licenses>=5.5.5,<6", "pytest>=9.0.3,<10", "pytest-asyncio>=1.4.0,<2", "pytest-cov>=7.1.0,<8", @@ -80,3 +88,6 @@ exclude_also = [ "if TYPE_CHECKING:", "if __name__ == .__main__.:", ] + +[tool.hatch.build.targets.wheel] +packages = ["src/my_mcp_server"] diff --git a/src/my_mcp_server/prompts/code_review.py b/src/my_mcp_server/prompts/code_review.py index b22bf7e..89ce350 100644 --- a/src/my_mcp_server/prompts/code_review.py +++ b/src/my_mcp_server/prompts/code_review.py @@ -11,7 +11,7 @@ from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.prompts.base import UserMessage -from pydantic import Field, validate_call +from pydantic import StringConstraints, validate_call NAME = "code-review" TITLE = "Code Review" @@ -30,7 +30,7 @@ @validate_call def code_review( language: Language, - code: Annotated[str, Field(min_length=1, description="Source code to review.")], + code: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)], ) -> list[UserMessage]: """Render a code-review prompt as a single-user-message template. diff --git a/src/my_mcp_server/server.py b/src/my_mcp_server/server.py index 1340dfe..1fb6e5c 100644 --- a/src/my_mcp_server/server.py +++ b/src/my_mcp_server/server.py @@ -10,7 +10,7 @@ from mcp.server.fastmcp import FastMCP from mcp.types import ToolAnnotations -from pydantic import Field +from pydantic import StringConstraints from my_mcp_server.prompts.code_review import register as register_code_review from my_mcp_server.resources.server_info import register as register_server_info @@ -22,14 +22,30 @@ # --------------------------------------------------------------------------- DEBUG = os.environ.get("MCP_DEBUG", "false").lower() == "true" +VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} + + +def resolve_log_level() -> str: + """Return a logging level name accepted by ``logging``.""" + if DEBUG: + return "DEBUG" + + candidate = os.environ.get("LOG_LEVEL", "INFO").upper() + if candidate not in VALID_LOG_LEVELS: + return "INFO" + return candidate + # --------------------------------------------------------------------------- # Server # --------------------------------------------------------------------------- +SERVER_NAME = "my-mcp-server" +SERVER_INSTRUCTIONS = "An MCP server. Replace this with your description." + mcp = FastMCP( - "my-mcp-server", - instructions="An MCP server. Replace this with your description.", + SERVER_NAME, + instructions=SERVER_INSTRUCTIONS, ) @@ -50,10 +66,10 @@ async def greet( name: Annotated[ str, - Field( + StringConstraints( + strip_whitespace=True, min_length=1, max_length=200, - description="Name to greet (1-200 characters).", ), ], ) -> str: @@ -64,8 +80,12 @@ async def greet( are rejected by the protocol layer before the handler runs. The TS sibling enforces the same shape via Zod. """ - logger.info("Greeting %s", name) - return f"Hello, {name}!" + normalized = name.strip() + if not normalized: + raise ValueError("name must contain at least one non-whitespace character") + + logger.info("Greeting %s", normalized) + return f"Hello, {normalized}!" # To add more tools, either decorate inline above or split them into modules @@ -94,9 +114,8 @@ async def greet( def main() -> None: """Run the MCP server.""" - log_level = "DEBUG" if DEBUG else os.environ.get("LOG_LEVEL", "INFO").upper() logging.basicConfig( - level=getattr(logging, log_level, logging.INFO), + level=getattr(logging, resolve_log_level()), format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", ) - mcp.run() + mcp.run(transport="stdio") diff --git a/tests/test_runtime_contract.py b/tests/test_runtime_contract.py new file mode 100644 index 0000000..9646f2d --- /dev/null +++ b/tests/test_runtime_contract.py @@ -0,0 +1,75 @@ +"""Tests for runtime metadata and startup contracts.""" + +from __future__ import annotations + +import logging +import tomllib +from pathlib import Path + +from my_mcp_server import server + + +def _project_table() -> dict[str, object]: + root = Path(__file__).resolve().parents[1] + with (root / "pyproject.toml").open("rb") as fh: + return tomllib.load(fh)["project"] + + +def test_console_script_uses_exit_code_wrapper() -> None: + """The installed command should use the same error-handling wrapper as + ``python -m my_mcp_server`` instead of calling the server loop directly.""" + project = _project_table() + assert project["scripts"]["my-mcp-server"] == "my_mcp_server.__main__:run" + + +def test_project_metadata_has_real_public_owner() -> None: + """The starter's package metadata should not ship the template placeholder.""" + project = _project_table() + assert project["authors"] == [{"name": "Heznpc"}] + assert "Homepage" in project["urls"] + + +def test_main_runs_stdio_transport(monkeypatch) -> None: + """MCP clients expect the packaged command to start the stdio transport.""" + calls: list[dict[str, str]] = [] + + def fake_run(**kwargs: str) -> None: + calls.append(kwargs) + + monkeypatch.setattr(server.mcp, "run", fake_run) + server.main() + + assert calls == [{"transport": "stdio"}] + + +def test_resolve_log_level_defaults_to_info(monkeypatch) -> None: + monkeypatch.delenv("MCP_DEBUG", raising=False) + monkeypatch.delenv("LOG_LEVEL", raising=False) + monkeypatch.setattr(server, "DEBUG", False) + + assert server.resolve_log_level() == "INFO" + + +def test_resolve_log_level_honors_debug_flag(monkeypatch) -> None: + monkeypatch.setenv("LOG_LEVEL", "ERROR") + monkeypatch.setattr(server, "DEBUG", True) + + assert server.resolve_log_level() == "DEBUG" + + +def test_resolve_log_level_rejects_unknown_values(monkeypatch) -> None: + monkeypatch.setenv("LOG_LEVEL", "NOPE") + monkeypatch.setattr(server, "DEBUG", False) + + assert server.resolve_log_level() == "INFO" + + +def test_resolve_log_level_accepts_known_values(monkeypatch) -> None: + monkeypatch.setenv("LOG_LEVEL", "warning") + monkeypatch.setattr(server, "DEBUG", False) + + assert server.resolve_log_level() == "WARNING" + + +def test_logging_level_is_known_to_stdlib() -> None: + assert getattr(logging, server.resolve_log_level()) diff --git a/tests/test_tools.py b/tests/test_tools.py index f87db19..4702b00 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -18,6 +18,12 @@ async def test_greet_custom_name(): assert result == "Hello, Ploidy!" +async def test_greet_strips_surrounding_whitespace(): + """Whitespace around a valid name is normalization, not part of the name.""" + result = await _validated_greet(name=" World ") + assert result == "Hello, World!" + + # --- input bound enforcement ----------------------------------------------- # Calling the tool function directly bypasses FastMCP's protocol-level # schema validation. Wrapping with `validate_call` is the same enforcement @@ -33,6 +39,12 @@ async def test_greet_rejects_empty_name(): await _validated_greet(name="") +async def test_greet_rejects_whitespace_only_name(): + """strip_whitespace + min_length=1 rejects blank-looking input.""" + with pytest.raises(ValidationError): + await _validated_greet(name=" ") + + async def test_greet_rejects_oversize_name(): """max_length=200 — long strings must be rejected.""" with pytest.raises(ValidationError):