Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 2 additions & 3 deletions src/my_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.


# ---------------------------------------------------------------------------
Expand Down
36 changes: 0 additions & 36 deletions src/my_mcp_server/tools/greet.py

This file was deleted.

57 changes: 57 additions & 0 deletions tests/test_server_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]