diff --git a/SECURITY.md b/SECURITY.md index 24a0da6..8da7e8c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,9 +22,50 @@ This template includes several security measures: `RUF`, `SIM`, plus correctness/style/import-order/naming/pyupgrade - **SHA-pinned third-party actions** — `softprops/action-gh-release`, `actions/stale`, `actions/github-script` are pinned to commit SHAs, not tags -- **Repo-side toggles enabled** — Secret scanning + push protection (blocks - commits that contain leaked secrets) + Dependabot security updates + - branch protection on `main` (required CI checks before merge) + +## What clones inherit vs. what they don't + +Everything above is **code-level** — it ships in the repo and a clone gets +it by virtue of having the same files. The items below are **runtime +GitHub settings** that GitHub does *not* copy when you create a new repo +from a template. **You must enable them on your fork.** + +Run these `gh` calls (or use the web UI: Settings → Security/Branches) on +your new repo: + +```bash +REPO=your-org/your-repo + +# Secret scanning + push protection (public repos: free; private: needs GHAS) +gh api -X PATCH "repos/$REPO" \ + -f 'security_and_analysis[secret_scanning][status]=enabled' \ + -f 'security_and_analysis[secret_scanning_push_protection][status]=enabled' + +# Dependabot security updates + vulnerability alerts +gh api -X PUT "repos/$REPO/vulnerability-alerts" +gh api -X PUT "repos/$REPO/automated-security-fixes" + +# Branch protection — adjust required checks to match your CI job names +gh api -X PUT "repos/$REPO/branches/main/protection" --input - <<'JSON' +{ + "required_status_checks": { + "strict": false, + "checks": [ + {"context": "security"}, {"context": "licenses"}, + {"context": "test (3.11)"}, {"context": "test (3.12)"}, {"context": "test (3.13)"} + ] + }, + "enforce_admins": false, + "required_pull_request_reviews": null, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false +} +JSON + +# Auto-merge + auto-delete merged branches +gh api -X PATCH "repos/$REPO" -F allow_auto_merge=true -F delete_branch_on_merge=true +``` ## Best Practices diff --git a/src/my_mcp_server/server.py b/src/my_mcp_server/server.py index 3735b9d..d0b956f 100644 --- a/src/my_mcp_server/server.py +++ b/src/my_mcp_server/server.py @@ -68,12 +68,11 @@ async def greet( return f"Hello, {name}!" -# To add more tools, create files in tools/ and register them: +# To add more tools, either decorate inline above or create a module under +# tools/ exposing a `register(mcp)` function and call it here, e.g.: # # from my_mcp_server.tools.your_tool import register # register(mcp) -# -# See tools/greet.py for the modular pattern. # --------------------------------------------------------------------------- diff --git a/src/my_mcp_server/tools/greet.py b/src/my_mcp_server/tools/greet.py deleted file mode 100644 index c187d8e..0000000 --- a/src/my_mcp_server/tools/greet.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Example tool module. - -This shows the modular pattern for organizing tools in separate files. -Use this when your server grows beyond a few tools. - -Usage in server.py: - from my_mcp_server.tools.greet import register - register(mcp) -""" - -from mcp.server.fastmcp import FastMCP -from mcp.types import ToolAnnotations - - -def register(mcp: FastMCP) -> None: - """Register greet tools on the server.""" - - @mcp.tool( - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), - ) - async def greet_formal(name: str, title: str = "Mr.") -> str: - """Greet someone formally. - - Args: - name: Name to greet. - title: Honorific title (default: Mr.). - - Returns: - A formal greeting. - """ - return f"Good day, {title} {name}." diff --git a/tests/test_server_info.py b/tests/test_server_info.py index 183eaf0..7a29f5b 100644 --- a/tests/test_server_info.py +++ b/tests/test_server_info.py @@ -63,3 +63,60 @@ async def test_registered_on_server() -> None: resources = await mcp.list_resources() uris = [str(r.uri) for r in resources] assert URI in uris + + +# --- wheel-install fallback path --------------------------------------------- +# When the package is installed from a wheel, pyproject.toml is NOT shipped, +# so `_read_pyproject()` returns None and `_server_metadata()` must fall back +# to `importlib.metadata.version()`. The tests above all exercise the source +# tree (where pyproject.toml IS reachable) — without these two cases, the +# production install path is 0% covered. + + +async def test_falls_back_to_importlib_metadata_when_pyproject_missing( + monkeypatch, +) -> None: + """Wheel install: _read_pyproject() returns None, version comes from metadata.""" + from my_mcp_server.resources import server_info as mod + + monkeypatch.setattr(mod, "_read_pyproject", lambda: None) + payload = json.loads(await mod.server_info()) + + # Package is installed editable in tests; importlib.metadata can find it. + project = _pyproject_project() + assert payload["name"] == "my-mcp-server" + assert payload["version"] == project["version"] + + +async def test_falls_back_to_zero_version_when_package_not_installed( + monkeypatch, +) -> None: + """Edge of the edge: pyproject missing AND importlib.metadata can't find + the dist. Should return '0.0.0' instead of raising PackageNotFoundError.""" + from my_mcp_server.resources import server_info as mod + + def raise_not_found(_name: str) -> str: + raise mod.PackageNotFoundError("simulated wheel-install-without-metadata") + + monkeypatch.setattr(mod, "_read_pyproject", lambda: None) + monkeypatch.setattr(mod, "version", raise_not_found) + + payload = json.loads(await mod.server_info()) + assert payload["version"] == "0.0.0" + + +def test_read_pyproject_returns_none_when_project_table_missing(tmp_path, monkeypatch) -> None: + """Walk-up finds a pyproject.toml with no [project] table — exit path + at line 41 (the `return None` inside the `is_file` branch).""" + from my_mcp_server.resources import server_info as mod + + # Build a fake directory tree: tmp_path/pkg/server_info.py with a + # pyproject.toml at tmp_path that lacks [project]. + pkg = tmp_path / "pkg" + pkg.mkdir() + (tmp_path / "pyproject.toml").write_text("[build-system]\nrequires = []\n") + fake_file = pkg / "server_info.py" + fake_file.write_text("") + + monkeypatch.setattr(mod, "__file__", str(fake_file)) + assert mod._read_pyproject() is None diff --git a/tests/test_tools.py b/tests/test_tools.py index ee91c97..4cb136b 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -44,3 +44,26 @@ async def test_greet_accepts_boundary_lengths(): assert await _validated_greet(name="a") == "Hello, a!" long_name = "a" * 200 assert await _validated_greet(name=long_name) == f"Hello, {long_name}!" + + +# --- protocol-level contract -------------------------------------------------- +# Parallels test_server_info.py::test_registered_on_server and +# test_code_review.py::test_registered_on_server — without this, the tool's +# JSON Schema generation (the actual MCP wire contract) is never exercised. + + +async def test_greet_registered_on_server(): + """The greet tool is wired into the server and its JSON Schema reflects + the Annotated[..., Field(min_length, max_length)] constraints.""" + from my_mcp_server.server import mcp + + tools = await mcp.list_tools() + by_name = {t.name: t for t in tools} + assert "greet" in by_name, f"greet missing from list_tools(); got {list(by_name)}" + + schema = by_name["greet"].inputSchema + name_prop = schema["properties"]["name"] + assert name_prop["type"] == "string" + assert name_prop["minLength"] == 1 + assert name_prop["maxLength"] == 200 + assert "name" in schema["required"]