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
16 changes: 15 additions & 1 deletion .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,21 @@ jobs:

- name: Label major updates for manual review
if: steps.meta.outputs.update-type == 'version-update:semver-major'
run: gh pr edit "$PR_URL" --add-label "dependencies,manual-review"
# 1. Ensure the `manual-review` label exists (idempotent; `--force`
# succeeds whether or not it pre-exists). Without this `gh pr edit
# --add-label` fails on a fresh repo, taking the whole job red.
# 2. Disable any prior `--auto` enable from an earlier classification
# of this same PR — Dependabot may rebase a minor into a major; the
# auto-merge wouldn't otherwise be revoked and the major would
# silently squash-merge once CI passes.
# 3. Apply the labels so a maintainer sees the PR in their queue.
run: |
gh label create manual-review \
--color fbca04 \
--description "Needs maintainer decision before merging" \
--force
gh pr merge --disable-auto "$PR_URL" || true
gh pr edit "$PR_URL" --add-label "dependencies,manual-review"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2 changes: 1 addition & 1 deletion .github/workflows/setup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
- [ ] Update `pyproject.toml` — change `name`, `description`, `authors`, `[project.scripts]` entry
- [ ] Rename `src/my_mcp_server/` package directory to match your project name
- [ ] Update imports and `pyproject.toml` scripts after rename
- [ ] Replace the example `greet` tool with your own tools in `src/<your_pkg>/tools/`
- [ ] Replace the inline `greet` example in `src/<your_pkg>/server.py` with your own tools (or split into modules — see `resources/` and `prompts/` for the `register(mcp)` pattern)
- [ ] Set safety annotations on each tool (readOnly, destructive, idempotent, openWorld)
- [ ] Update tests in `tests/`

Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/stale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ jobs:
This PR has been inactive for 30 days and will be closed in 7 days
unless there is new activity.
exempt-issue-labels: pinned,bug,help wanted
exempt-pr-labels: pinned,work in progress
# `dependencies` + `manual-review` keep Dependabot major-bump PRs from
# being silently auto-closed before a maintainer can decide.
exempt-pr-labels: pinned,work in progress,dependencies,manual-review
40 changes: 4 additions & 36 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,40 +18,8 @@ people reading this file directly. Clear them by hand when cutting a release.

## [Unreleased]

### Added
- `actions/attest-build-provenance@v3` step in `cd.yml` — wheel/sdist now
ship with sigstore-signed SLSA build provenance attestations; PyPI surfaces
this as "Build attestations: verified".
- CodeQL now scans `actions` workflows in addition to `python` source, so
workflow injection / missing-permissions issues are caught alongside code
issues.
- `pytest-cov` to `[dev]` deps + `--cov-fail-under=70` baseline in `pyproject.toml`
(matches measured 71% coverage; bump only upward).
- Ruff rule set extended with `B` (bugbear), `S` (bandit subset), `ASYNC`,
`RUF`, `SIM` — security/correctness checks beyond the previous style-only set.
- `.github/CODEOWNERS` — solo-dev auto-assignment for Dependabot PRs and outside
contributions.
- `.github/ISSUE_TEMPLATE/{bug_report,feature_request,config}.yml` +
`.github/PULL_REQUEST_TEMPLATE.md` — minimal templates with scope checks.
- `.python-version` (pyenv/uv users).

### Changed
- Third-party Actions are now SHA-pinned (with `# vX.Y.Z` comment for human
legibility): `softprops/action-gh-release` v3.0.0, `actions/stale` v10.2.0,
`actions/github-script` v9.0.0, `actions/attest-build-provenance` v3.2.0.
First-party `actions/*`, `github/codeql-action/*`, and
`pypa/gh-action-pypi-publish@release/v1` keep tag pinning per their respective
publisher guidance.
- Dev tooling now has major-version bounds: `ruff>=0.15,<1`, `mypy>=2,<3`,
`pytest>=8,<10`, `pytest-asyncio>=1,<2`. Prevents a surprise major release
from turning CI red overnight.
- `SECURITY.md` documents the full feature set including the GitHub repo-side
toggles (secret scanning + push protection + Dependabot security updates +
branch protection on `main`).

### Fixed
- Stop tracking `.coverage` and add coverage artifacts (`.coverage*`, `htmlcov/`,
`coverage.xml`) to `.gitignore` so local test runs no longer pollute commits.
- Replace EN DASH (`–`) with hyphen-minus in `greet` field description
(`server.py:71`) — ruff `RUF001` flagged the ambiguous character.
_None yet — see [GitHub Releases](https://github.com/starter-series/python-mcp-server-starter/releases)
for the authoritative history. Hand-written bullets here are a courtesy for
people reading this file directly; clear them when cutting a release so the
auto-prepended release notes stay the canonical record._

5 changes: 1 addition & 4 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,7 @@ python -m my_mcp_server
src/my_mcp_server/
├── __init__.py # 버전
├── __main__.py # python -m 진입점
├── server.py # FastMCP 서버 + 인라인 툴 + 헬퍼
├── tools/
│ ├── __init__.py
│ └── greet.py # 모듈형 툴 예시
├── server.py # FastMCP 서버 + 인라인 `greet` 툴 예시
├── resources/
│ ├── __init__.py
│ └── server_info.py # Resource 예시 (info://server/status)
Expand Down
36 changes: 6 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,32 +82,11 @@ async def your_tool(input: str) -> str:

### Modular (recommended for larger servers)

Create `src/my_mcp_server/tools/your_tool.py`:

```python
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations


def register(mcp: FastMCP) -> None:
@mcp.tool(
annotations=ToolAnnotations(
readOnlyHint=True,
destructiveHint=False,
idempotentHint=True,
),
)
async def your_tool(input: str) -> str:
"""What your tool does."""
return f"Processed: {input}"
```

Then in `server.py`:

```python
from my_mcp_server.tools.your_tool import register
register(mcp)
```
See `resources/server_info.py` and `prompts/code_review.py` for the
`register(mcp)` pattern this repo applies to resources and prompts. The
same shape works for tools: define `register(mcp: FastMCP) -> None` in
`src/my_mcp_server/tools/your_tool.py`, decorate `@mcp.tool(...)` inside,
then call `register(mcp)` from `server.py`.

## Adding Resources

Expand Down Expand Up @@ -213,10 +192,7 @@ Setup: [PyPI OIDC trusted publishing docs](https://docs.pypi.org/trusted-publish
src/my_mcp_server/
├── __init__.py # Version
├── __main__.py # python -m entry point
├── server.py # FastMCP server + inline tools + helpers
├── tools/
│ ├── __init__.py
│ └── greet.py # Example modular tool
├── server.py # FastMCP server + inline `greet` tool example
├── resources/
│ ├── __init__.py
│ └── server_info.py # Example resource (info://server/status)
Expand Down
17 changes: 16 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ your new repo:
```bash
REPO=your-org/your-repo

# Most of these endpoints need repo admin AND token scopes that the default
# `gh auth login` flow does not request. Refresh first:
gh auth refresh -s admin:repo,security_events

# Secret scanning + push protection (public repos: free; private: needs GHAS)
gh api -X PATCH "repos/$REPO" \
-f 'security_and_analysis[secret_scanning][status]=enabled' \
Expand All @@ -45,7 +49,12 @@ gh api -X PATCH "repos/$REPO" \
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
# Branch protection — the `checks` list below MUST match the actual job names
# your ci.yml produces. The example matches this repo's matrix
# (`security`, `licenses`, plus `test (3.11/3.12/3.13)`). If you change the
# Python matrix (drop 3.11, add 3.14, rename `test`) UPDATE THIS LIST IN
# LOCKSTEP — otherwise required checks point at jobs that never report and
# every PR sits in "Expected — waiting for status" forever.
gh api -X PUT "repos/$REPO/branches/main/protection" --input - <<'JSON'
{
"required_status_checks": {
Expand All @@ -65,6 +74,12 @@ JSON

# Auto-merge + auto-delete merged branches
gh api -X PATCH "repos/$REPO" -F allow_auto_merge=true -F delete_branch_on_merge=true

# Dependabot auto-merge needs a `manual-review` label (referenced by
# .github/workflows/dependabot-auto-merge.yml). The workflow creates it
# idempotently on first major-bump PR, but you can seed it now:
gh label create manual-review --color fbca04 \
--description "Needs maintainer decision before merging" --force
```

## Best Practices
Expand Down
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,10 @@ branch = true
source = ["my_mcp_server"]

[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
# Defaults already cover `pragma: no cover`. Add patterns here when you
# introduce code that should NOT count against coverage — e.g.
# `if TYPE_CHECKING:` blocks, `raise NotImplementedError` stubs.
exclude_also = [
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
"raise NotImplementedError",
]
33 changes: 25 additions & 8 deletions src/my_mcp_server/__main__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
"""Allow running as ``python -m my_mcp_server``."""
"""Allow running as ``python -m my_mcp_server``.

The wrap-and-exit logic lives in ``run()`` so it can be tested directly
(``import my_mcp_server.__main__`` no longer blocks on the server loop).
"""

import logging
import sys

from my_mcp_server.server import main

try:
main()
except KeyboardInterrupt:
sys.exit(0)
except Exception:
logging.exception("Fatal error running MCP server")
sys.exit(1)

def run() -> int:
"""Run ``server.main()`` and translate termination into a process exit code.

Returns:
``0`` on clean return or Ctrl-C, ``1`` on any other exception
(logged via ``logging.exception`` so the traceback hits stderr).
"""
try:
main()
except KeyboardInterrupt:
return 0
except Exception:
logging.exception("Fatal error running MCP server")
return 1
return 0


if __name__ == "__main__":
sys.exit(run())
16 changes: 12 additions & 4 deletions src/my_mcp_server/resources/server_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
DESCRIPTION = "Server metadata: name, version, Python runtime, and platform."
MIME_TYPE = "application/json"

# Convention: PyPI distribution name = import package name with underscores
# rewritten as hyphens. Derived from `__package__` so a clone that renames
# `src/my_mcp_server/` (per setup.yml's first-run checklist) doesn't have to
# update a second hardcoded literal — the wheel-install fallback below picks
# the new name up automatically.
PKG_NAME = (__package__ or "my_mcp_server").split(".")[0].replace("_", "-")
FALLBACK_VERSION = "0.0.0"


def _read_pyproject() -> dict[str, str] | None:
"""Walk up from this file to locate pyproject.toml and parse it.
Expand All @@ -45,15 +53,15 @@ def _read_pyproject() -> dict[str, str] | None:
def _server_metadata() -> dict[str, object]:
project = _read_pyproject()
if project is not None:
pkg_name = str(project.get("name", "my-mcp-server"))
pkg_version = str(project.get("version", "0.0.0"))
pkg_name = str(project.get("name", PKG_NAME))
pkg_version = str(project.get("version", FALLBACK_VERSION))
else:
# Fallback for wheel installs where pyproject.toml isn't shipped.
pkg_name = "my-mcp-server"
pkg_name = PKG_NAME
try:
pkg_version = version(pkg_name)
except PackageNotFoundError:
pkg_version = "0.0.0"
pkg_version = FALLBACK_VERSION

return {
"name": pkg_name,
Expand Down
12 changes: 5 additions & 7 deletions src/my_mcp_server/server.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""MCP server entry point.

Registers tools, resources, and prompts via FastMCP.
Add your own tools in the tools/ directory following the greet.py pattern.
Registers tools, resources, and prompts via FastMCP. Add your own tools
inline below the existing `greet` example, or split them into modules.
"""

import logging
Expand Down Expand Up @@ -68,11 +68,9 @@ async def greet(
return f"Hello, {name}!"


# 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)
# To add more tools, either decorate inline above or split them into modules
# (see resources/server_info.py and prompts/code_review.py for the `register(mcp)`
# pattern this repo applies to resources and prompts).


# ---------------------------------------------------------------------------
Expand Down
Empty file.
46 changes: 46 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Tests for the ``python -m my_mcp_server`` entry-point wrapper.

These exercise ``__main__.run()`` directly — the body of __main__.py is
guarded by ``if __name__ == "__main__":`` so importing the module no
longer blocks on the server loop, which is what makes these tests possible.
"""

from __future__ import annotations

import logging

from my_mcp_server import __main__ as entry


def test_run_returns_zero_on_clean_exit(monkeypatch) -> None:
"""main() returns normally → run() returns 0."""
monkeypatch.setattr(entry, "main", lambda: None)
assert entry.run() == 0


def test_run_returns_zero_on_keyboard_interrupt(monkeypatch) -> None:
"""Ctrl-C during main() → run() exits cleanly with 0."""

def raise_keyboard_interrupt() -> None:
raise KeyboardInterrupt()

monkeypatch.setattr(entry, "main", raise_keyboard_interrupt)
assert entry.run() == 0


def test_run_returns_one_and_logs_on_unhandled_exception(monkeypatch, caplog) -> None:
"""Any other exception → run() returns 1 AND logs the traceback. Both
halves are part of the contract: silently exiting 1 without the log
would make production debugging much harder."""

def boom() -> None:
raise RuntimeError("simulated server crash")

monkeypatch.setattr(entry, "main", boom)

with caplog.at_level(logging.ERROR):
rc = entry.run()

assert rc == 1
assert "Fatal error running MCP server" in caplog.text
assert "simulated server crash" in caplog.text
Loading