release-notes-mcp 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ .eggs/
7
+ build/
8
+ dist/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # Config with secrets
16
+ config.json
17
+
18
+ # OS
19
+ .DS_Store
20
+
21
+ # Editor / IDE
22
+ .idea/
23
+ .vscode/
24
+
25
+ # Git
26
+ .git/
27
+ .gitignore
28
+
29
+ # Docker
30
+ Dockerfile
31
+ .dockerignore
32
+ compose.yaml
@@ -0,0 +1,23 @@
1
+ # Copy to `.env` and fill in. `.env` is gitignored.
2
+
3
+ # Auth token for the configured provider (GitHub PAT, GitLab token, Gitea token).
4
+ # Provider-agnostic — just `TOKEN`. Only needs read access. Optional for public repos.
5
+ TOKEN=
6
+
7
+ # Which forge to read releases from: github | gitlab | gitea
8
+ PROVIDER=github
9
+
10
+ # Base API URL — only needed for self-hosted GitLab or for Gitea/Forgejo.
11
+ # github default: https://api.github.com gitlab default: https://gitlab.com/api/v4
12
+ # BASE_URL=https://git.example.com/api/v1
13
+
14
+ # Config source (repos + contextSources). Pick one:
15
+ # - a file (Docker uses this; bind-mounted to /app/config.json):
16
+ # RELEASE_MCP_CONFIG=/abs/path/config.json
17
+ # - or inline JSON (no file needed — handy for uvx / MCP hubs):
18
+ # RELEASE_MCP_CONFIG_JSON={"repos":["myorg/web"],"contextSources":[]}
19
+
20
+ # --- optional transport overrides (defaults shown) ---
21
+ # MCP_TRANSPORT=http
22
+ # MCP_HOST=0.0.0.0
23
+ # MCP_PORT=8000
@@ -0,0 +1,99 @@
1
+ name: release
2
+
3
+ # Builds the Docker image and publishes it to GitHub Container Registry (ghcr.io)
4
+ # on each published GitHub Release. Image is tagged with the release tag + latest.
5
+ on:
6
+ release:
7
+ types: [published]
8
+
9
+ env:
10
+ REGISTRY: ghcr.io
11
+ IMAGE_NAME: ${{ github.repository }} # ghcr.io/<owner>/<repo>
12
+
13
+ jobs:
14
+ build:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: read
18
+ packages: write # required to push to ghcr.io
19
+
20
+ steps:
21
+ - name: Checkout
22
+ uses: actions/checkout@v7
23
+
24
+ - name: Set up Buildx
25
+ uses: docker/setup-buildx-action@v4
26
+
27
+ - name: Log in to ghcr.io
28
+ uses: docker/login-action@v4
29
+ with:
30
+ registry: ${{ env.REGISTRY }}
31
+ username: ${{ github.actor }}
32
+ password: ${{ secrets.GITHUB_TOKEN }}
33
+
34
+ - name: Build and push
35
+ uses: docker/build-push-action@v7
36
+ with:
37
+ context: .
38
+ platforms: linux/amd64,linux/arm64
39
+ push: true
40
+ tags: |
41
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
42
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
43
+ cache-from: type=gha
44
+ cache-to: type=gha,mode=max
45
+
46
+ # Build the wheel + sdist and publish to PyPI so the server can be run with
47
+ # `uvx release-notes-mcp`. Auth is PyPI Trusted Publishing (OIDC) — no secret;
48
+ # the `id-token: write` permission lets uv mint a short-lived token.
49
+ pypi:
50
+ runs-on: ubuntu-latest
51
+ permissions:
52
+ contents: read
53
+ id-token: write # required for PyPI Trusted Publishing (OIDC)
54
+ steps:
55
+ - name: Checkout
56
+ uses: actions/checkout@v7
57
+ with:
58
+ fetch-depth: 0 # hatch-vcs derives the version from git tags
59
+
60
+ - name: Set up uv
61
+ uses: astral-sh/setup-uv@v6
62
+
63
+ - name: Build
64
+ run: uv build
65
+
66
+ - name: Publish to PyPI
67
+ run: uv publish
68
+
69
+ # List the server in the official MCP Registry (registry.modelcontextprotocol.io)
70
+ # so MCP hubs/clients can discover it. Runs after the package is on PyPI, since
71
+ # the registry validates that the referenced PyPI version exists. Auth is GitHub
72
+ # OIDC — no secret; the namespace io.github.vaggeliskls/* is owned by this repo.
73
+ registry:
74
+ needs: pypi
75
+ runs-on: ubuntu-latest
76
+ permissions:
77
+ contents: read
78
+ id-token: write # required for `mcp-publisher login github-oidc`
79
+ steps:
80
+ - name: Checkout
81
+ uses: actions/checkout@v7
82
+
83
+ - name: Sync server.json version to the release tag
84
+ env:
85
+ TAG: ${{ github.event.release.tag_name }}
86
+ run: |
87
+ VERSION="${TAG#v}"
88
+ jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' \
89
+ server.json > server.tmp && mv server.tmp server.json
90
+
91
+ - name: Install mcp-publisher
92
+ run: |
93
+ curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
94
+
95
+ - name: Authenticate to MCP Registry
96
+ run: ./mcp-publisher login github-oidc
97
+
98
+ - name: Publish to MCP Registry
99
+ run: ./mcp-publisher publish
@@ -0,0 +1,23 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ .eggs/
7
+ build/
8
+ dist/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # Config with secrets
16
+ config.json
17
+
18
+ # OS
19
+ .DS_Store
20
+
21
+ # Editor / IDE
22
+ .idea/
23
+ .vscode/
@@ -0,0 +1,29 @@
1
+ # --- build stage: install deps into a self-contained dir -------------------- #
2
+ # Match the Python minor version of the distroless runtime (debian12 → 3.11).
3
+ FROM python:3.11-slim AS build
4
+
5
+ WORKDIR /build
6
+ COPY requirements.txt .
7
+ # Install into /deps so we can copy just the packages into the distroless image.
8
+ RUN pip install --no-cache-dir --target=/deps -r requirements.txt
9
+
10
+ # --- runtime stage: distroless, no shell, no pip ---------------------------- #
11
+ FROM gcr.io/distroless/python3-debian12:nonroot
12
+
13
+ WORKDIR /app
14
+
15
+ # Third-party packages and the server code.
16
+ COPY --from=build /deps /deps
17
+ COPY server.py .
18
+
19
+ ENV PYTHONPATH=/deps \
20
+ PYTHONUNBUFFERED=1 \
21
+ MCP_TRANSPORT=http \
22
+ MCP_HOST=0.0.0.0 \
23
+ MCP_PORT=8000 \
24
+ RELEASE_MCP_CONFIG=/app/config.json
25
+
26
+ EXPOSE 8000
27
+
28
+ # distroless python3 image's entrypoint is already `python3`.
29
+ CMD ["server.py"]
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: release-notes-mcp
3
+ Version: 1.0.0
4
+ Summary: A small, generic MCP server for combining GitHub releases into product release notes
5
+ Project-URL: Homepage, https://github.com/vaggeliskls/release-notes-mcp
6
+ Project-URL: Repository, https://github.com/vaggeliskls/release-notes-mcp
7
+ Keywords: gitea,github,gitlab,mcp,release-notes
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: fastmcp>=2.0
10
+ Requires-Dist: httpx>=0.27
11
+ Description-Content-Type: text/markdown
12
+
13
+ # release-notes-mcp
14
+
15
+ <!-- mcp-name: io.github.vaggeliskls/release-notes-mcp -->
16
+
17
+ A small, generic MCP server that combines GitHub releases from several
18
+ repositories into a single product release note. The server just fetches and
19
+ bundles raw data; the LLM synthesizes the final notes.
20
+
21
+ Nothing is architecture-specific:
22
+
23
+ - **`provider`** — which forge to read releases from: `github` (default),
24
+ `gitlab`, or `gitea`/Forgejo. Release fetching goes through a small adapter,
25
+ so adding a forge means normalizing its release JSON — a contained change.
26
+ - **`repos`** — the repos the server is allowed to read releases from.
27
+ - **`contextSources`** — arbitrary URLs loaded as background context (a style
28
+ guide, a versions file, feature names — anything). The server assigns no
29
+ meaning; what each source *is* is decided by what you put behind the URL.
30
+
31
+ ## Configuration
32
+
33
+ Config holds **no secrets** — only the repo set and context. Provider and auth
34
+ come from the environment.
35
+
36
+ ```jsonc
37
+ // config.json — non-sensitive (required; the server errors if it's missing)
38
+ {
39
+ "repos": [
40
+ "myorg/auth-service",
41
+ "myorg/web"
42
+ ],
43
+ "contextSources": [
44
+ {
45
+ "name": "release-info",
46
+ "url": "https://example.github.io/whatever/release.json",
47
+ "description": "Extra context to consult when assembling release notes"
48
+ }
49
+ ]
50
+ }
51
+ ```
52
+
53
+ Environment (provider-agnostic, set in `.env` or your shell):
54
+
55
+ | Var | Purpose | Default |
56
+ |-----|---------|---------|
57
+ | `TOKEN` | Auth token for the provider — **never in config** | _(empty; ok for public repos)_ |
58
+ | `PROVIDER` | `github` \| `gitlab` \| `gitea` (overrides config) | `github` |
59
+ | `BASE_URL` | API base — only for self-hosted GitLab / Gitea | provider default |
60
+
61
+ - `format` on a context source is **optional** — auto-detected from
62
+ `Content-Type` / URL extension / content sniffing. Override only when wrong.
63
+
64
+ **The config (repos + contextSources) must come from one of two places** — the
65
+ server errors on startup if neither is set:
66
+
67
+ | Source | Use it for |
68
+ |--------|-----------|
69
+ | `RELEASE_MCP_CONFIG_JSON` | The config as **inline JSON**. No file needed — ideal for `uvx` / MCP hubs where everything is an env var. |
70
+ | `RELEASE_MCP_CONFIG` | Path to a `config.json` **file** (default `./config.json`). Used by the container, which mounts a real file. |
71
+
72
+ Inline JSON wins when both are set. Copy `config.example.json` to get started
73
+ with the file approach.
74
+
75
+ ## Tools
76
+
77
+ | Tool | Purpose |
78
+ |------|---------|
79
+ | `list_repos()` | The configured repos |
80
+ | `list_releases(repo, limit)` | Recent releases for one repo |
81
+ | `get_latest_version(repo)` | Newest release for one repo |
82
+ | `get_release(repo, tag)` | Full notes for one tag |
83
+ | `compare_releases(repo, from_tag, to_tag)` | All releases between two versions |
84
+ | `gather_release_notes(selections[])` | Bundle raw notes from N `(repo, tag)` pairs (concurrent) |
85
+ | `get_context(name?)` | Load configured context URLs (auto-detected format) |
86
+
87
+ Selection is **dynamic** — you (or Claude) pass the `(repo, tag)` pairs to
88
+ combine. The server's `instructions` tell Claude to call `get_context()` first.
89
+
90
+ ## Run
91
+
92
+ The server runs in a container over **HTTP transport** on `localhost:8000`.
93
+ First create the config and env files (both runs need them):
94
+
95
+ ```bash
96
+ cp config.example.json config.json # edit repos + contextSources (no secrets)
97
+ cp .env.example .env # set TOKEN (+ PROVIDER / BASE_URL if needed)
98
+ ```
99
+
100
+ ### Normal run
101
+
102
+ ```bash
103
+ docker compose up -d
104
+ ```
105
+
106
+ ### Local development — `docker compose watch`
107
+
108
+ For local dev, `docker compose watch` keeps the server live while you edit:
109
+
110
+ ```bash
111
+ docker compose watch
112
+ ```
113
+
114
+ | Change | Action |
115
+ |--------|--------|
116
+ | `server.py` | **sync + restart** — copied into the container, process restarts |
117
+ | `requirements.txt`, `Dockerfile` | **rebuild** — image is rebuilt automatically |
118
+ | `config.json` | bind-mounted (live); run `docker compose restart` to reload it |
119
+
120
+ ### Run with `uvx` (no clone, no container)
121
+
122
+ The server is published to PyPI, so a client can launch it on demand with
123
+ [`uvx`](https://docs.astral.sh/uv/) — no checkout and no Docker:
124
+
125
+ ```bash
126
+ uvx release-notes-mcp
127
+ ```
128
+
129
+ `uvx` talks to the server over **stdio** (the default transport). Since there's
130
+ no file to mount, pass the config **inline** as JSON via `RELEASE_MCP_CONFIG_JSON`
131
+ (everything is env-only — ideal for MCP hubs):
132
+
133
+ ```bash
134
+ RELEASE_MCP_CONFIG_JSON='{"repos":["myorg/web"],"contextSources":[]}' \
135
+ TOKEN=ghp_... uvx release-notes-mcp
136
+ ```
137
+
138
+ Prefer a file? Point `RELEASE_MCP_CONFIG` at an **absolute** path instead
139
+ (`uvx` runs from an unknown working directory, so a relative path won't resolve):
140
+
141
+ ```bash
142
+ RELEASE_MCP_CONFIG=/abs/path/config.json TOKEN=ghp_... uvx release-notes-mcp
143
+ ```
144
+
145
+ ## Register with Claude Code
146
+
147
+ **HTTP (container)** — point Claude Code at the running server by its URL:
148
+
149
+ ```bash
150
+ claude mcp add --transport http release-notes http://localhost:8000/mcp
151
+ ```
152
+
153
+ **stdio (`uvx`)** — let Claude Code launch the server as a subprocess:
154
+
155
+ ```bash
156
+ claude mcp add release-notes \
157
+ --env RELEASE_MCP_CONFIG=/abs/path/config.json \
158
+ --env TOKEN=ghp_... \
159
+ -- uvx release-notes-mcp
160
+ ```
161
+
162
+ Then ask Claude: *"Combine the latest releases of auth-service and web into a
163
+ product release note."*
@@ -0,0 +1,151 @@
1
+ # release-notes-mcp
2
+
3
+ <!-- mcp-name: io.github.vaggeliskls/release-notes-mcp -->
4
+
5
+ A small, generic MCP server that combines GitHub releases from several
6
+ repositories into a single product release note. The server just fetches and
7
+ bundles raw data; the LLM synthesizes the final notes.
8
+
9
+ Nothing is architecture-specific:
10
+
11
+ - **`provider`** — which forge to read releases from: `github` (default),
12
+ `gitlab`, or `gitea`/Forgejo. Release fetching goes through a small adapter,
13
+ so adding a forge means normalizing its release JSON — a contained change.
14
+ - **`repos`** — the repos the server is allowed to read releases from.
15
+ - **`contextSources`** — arbitrary URLs loaded as background context (a style
16
+ guide, a versions file, feature names — anything). The server assigns no
17
+ meaning; what each source *is* is decided by what you put behind the URL.
18
+
19
+ ## Configuration
20
+
21
+ Config holds **no secrets** — only the repo set and context. Provider and auth
22
+ come from the environment.
23
+
24
+ ```jsonc
25
+ // config.json — non-sensitive (required; the server errors if it's missing)
26
+ {
27
+ "repos": [
28
+ "myorg/auth-service",
29
+ "myorg/web"
30
+ ],
31
+ "contextSources": [
32
+ {
33
+ "name": "release-info",
34
+ "url": "https://example.github.io/whatever/release.json",
35
+ "description": "Extra context to consult when assembling release notes"
36
+ }
37
+ ]
38
+ }
39
+ ```
40
+
41
+ Environment (provider-agnostic, set in `.env` or your shell):
42
+
43
+ | Var | Purpose | Default |
44
+ |-----|---------|---------|
45
+ | `TOKEN` | Auth token for the provider — **never in config** | _(empty; ok for public repos)_ |
46
+ | `PROVIDER` | `github` \| `gitlab` \| `gitea` (overrides config) | `github` |
47
+ | `BASE_URL` | API base — only for self-hosted GitLab / Gitea | provider default |
48
+
49
+ - `format` on a context source is **optional** — auto-detected from
50
+ `Content-Type` / URL extension / content sniffing. Override only when wrong.
51
+
52
+ **The config (repos + contextSources) must come from one of two places** — the
53
+ server errors on startup if neither is set:
54
+
55
+ | Source | Use it for |
56
+ |--------|-----------|
57
+ | `RELEASE_MCP_CONFIG_JSON` | The config as **inline JSON**. No file needed — ideal for `uvx` / MCP hubs where everything is an env var. |
58
+ | `RELEASE_MCP_CONFIG` | Path to a `config.json` **file** (default `./config.json`). Used by the container, which mounts a real file. |
59
+
60
+ Inline JSON wins when both are set. Copy `config.example.json` to get started
61
+ with the file approach.
62
+
63
+ ## Tools
64
+
65
+ | Tool | Purpose |
66
+ |------|---------|
67
+ | `list_repos()` | The configured repos |
68
+ | `list_releases(repo, limit)` | Recent releases for one repo |
69
+ | `get_latest_version(repo)` | Newest release for one repo |
70
+ | `get_release(repo, tag)` | Full notes for one tag |
71
+ | `compare_releases(repo, from_tag, to_tag)` | All releases between two versions |
72
+ | `gather_release_notes(selections[])` | Bundle raw notes from N `(repo, tag)` pairs (concurrent) |
73
+ | `get_context(name?)` | Load configured context URLs (auto-detected format) |
74
+
75
+ Selection is **dynamic** — you (or Claude) pass the `(repo, tag)` pairs to
76
+ combine. The server's `instructions` tell Claude to call `get_context()` first.
77
+
78
+ ## Run
79
+
80
+ The server runs in a container over **HTTP transport** on `localhost:8000`.
81
+ First create the config and env files (both runs need them):
82
+
83
+ ```bash
84
+ cp config.example.json config.json # edit repos + contextSources (no secrets)
85
+ cp .env.example .env # set TOKEN (+ PROVIDER / BASE_URL if needed)
86
+ ```
87
+
88
+ ### Normal run
89
+
90
+ ```bash
91
+ docker compose up -d
92
+ ```
93
+
94
+ ### Local development — `docker compose watch`
95
+
96
+ For local dev, `docker compose watch` keeps the server live while you edit:
97
+
98
+ ```bash
99
+ docker compose watch
100
+ ```
101
+
102
+ | Change | Action |
103
+ |--------|--------|
104
+ | `server.py` | **sync + restart** — copied into the container, process restarts |
105
+ | `requirements.txt`, `Dockerfile` | **rebuild** — image is rebuilt automatically |
106
+ | `config.json` | bind-mounted (live); run `docker compose restart` to reload it |
107
+
108
+ ### Run with `uvx` (no clone, no container)
109
+
110
+ The server is published to PyPI, so a client can launch it on demand with
111
+ [`uvx`](https://docs.astral.sh/uv/) — no checkout and no Docker:
112
+
113
+ ```bash
114
+ uvx release-notes-mcp
115
+ ```
116
+
117
+ `uvx` talks to the server over **stdio** (the default transport). Since there's
118
+ no file to mount, pass the config **inline** as JSON via `RELEASE_MCP_CONFIG_JSON`
119
+ (everything is env-only — ideal for MCP hubs):
120
+
121
+ ```bash
122
+ RELEASE_MCP_CONFIG_JSON='{"repos":["myorg/web"],"contextSources":[]}' \
123
+ TOKEN=ghp_... uvx release-notes-mcp
124
+ ```
125
+
126
+ Prefer a file? Point `RELEASE_MCP_CONFIG` at an **absolute** path instead
127
+ (`uvx` runs from an unknown working directory, so a relative path won't resolve):
128
+
129
+ ```bash
130
+ RELEASE_MCP_CONFIG=/abs/path/config.json TOKEN=ghp_... uvx release-notes-mcp
131
+ ```
132
+
133
+ ## Register with Claude Code
134
+
135
+ **HTTP (container)** — point Claude Code at the running server by its URL:
136
+
137
+ ```bash
138
+ claude mcp add --transport http release-notes http://localhost:8000/mcp
139
+ ```
140
+
141
+ **stdio (`uvx`)** — let Claude Code launch the server as a subprocess:
142
+
143
+ ```bash
144
+ claude mcp add release-notes \
145
+ --env RELEASE_MCP_CONFIG=/abs/path/config.json \
146
+ --env TOKEN=ghp_... \
147
+ -- uvx release-notes-mcp
148
+ ```
149
+
150
+ Then ask Claude: *"Combine the latest releases of auth-service and web into a
151
+ product release note."*
@@ -0,0 +1,36 @@
1
+ services:
2
+ release-notes-mcp:
3
+ # Published by .github/workflows/release.yml on each GitHub Release.
4
+ # `docker compose watch` builds locally and tags it with this same name.
5
+ image: ghcr.io/vaggeliskls/release-notes-mcp:latest
6
+ build: .
7
+ restart: unless-stopped
8
+ ports:
9
+ - "8000:8000"
10
+ environment:
11
+ # Provider + auth come from .env (provider-agnostic). Token is never in config.
12
+ PROVIDER: ${PROVIDER:-github}
13
+ BASE_URL: ${BASE_URL:-}
14
+ TOKEN: ${TOKEN:-}
15
+ MCP_TRANSPORT: http
16
+ MCP_HOST: 0.0.0.0
17
+ MCP_PORT: "8000"
18
+ RELEASE_MCP_CONFIG: /app/config.json
19
+ volumes:
20
+ # Mount your real config read-only; keep it out of the image.
21
+ # Edits appear live; run `docker compose restart` to reload (read at startup).
22
+ - ./config.json:/app/config.json:ro
23
+
24
+ # `docker compose watch` — develop against the container.
25
+ # https://docs.docker.com/reference/compose-file/develop/
26
+ develop:
27
+ watch:
28
+ # Edit server.py → sync into the container and restart the process.
29
+ - path: ./server.py
30
+ target: /app/server.py
31
+ action: sync+restart
32
+ # Dependency or image changes → rebuild the image.
33
+ - path: ./requirements.txt
34
+ action: rebuild
35
+ - path: ./Dockerfile
36
+ action: rebuild
@@ -0,0 +1,14 @@
1
+ {
2
+ "repos": [
3
+ "myorg/auth-service",
4
+ "myorg/web",
5
+ "myorg/payments-api"
6
+ ],
7
+ "contextSources": [
8
+ {
9
+ "name": "release-info",
10
+ "url": "https://turintech.github.io/artemis-deployment/release.json",
11
+ "description": "Extra context to consult when assembling release notes"
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "release-notes-mcp"
7
+ description = "A small, generic MCP server for combining GitHub releases into product release notes"
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ keywords = ["mcp", "release-notes", "github", "gitlab", "gitea"]
11
+ dynamic = ["version"] # derived from the git tag by hatch-vcs
12
+ dependencies = [
13
+ "fastmcp>=2.0",
14
+ "httpx>=0.27",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/vaggeliskls/release-notes-mcp"
19
+ Repository = "https://github.com/vaggeliskls/release-notes-mcp"
20
+
21
+ [project.scripts]
22
+ release-notes-mcp = "server:main"
23
+
24
+ # Version comes from the latest git tag (e.g. tag `v0.2.0` -> version `0.2.0`).
25
+ [tool.hatch.version]
26
+ source = "vcs"
27
+
28
+ # server.py is a single top-level module (not a package); tell hatchling to ship it.
29
+ [tool.hatch.build.targets.wheel]
30
+ include = ["server.py"]
@@ -0,0 +1,2 @@
1
+ fastmcp==3.4.2
2
+ httpx==0.28.1
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.vaggeliskls/release-notes-mcp",
4
+ "description": "A small, generic MCP server for combining GitHub/GitLab/Gitea releases into product release notes",
5
+ "repository": {
6
+ "url": "https://github.com/vaggeliskls/release-notes-mcp",
7
+ "source": "github"
8
+ },
9
+ "version": "0.1.0",
10
+ "packages": [
11
+ {
12
+ "registryType": "pypi",
13
+ "registryBaseUrl": "https://pypi.org",
14
+ "identifier": "release-notes-mcp",
15
+ "version": "0.1.0",
16
+ "runtimeHint": "uvx",
17
+ "transport": {
18
+ "type": "stdio"
19
+ },
20
+ "environmentVariables": [
21
+ {
22
+ "name": "RELEASE_MCP_CONFIG_JSON",
23
+ "description": "Config (repos + contextSources) as inline JSON, e.g. {\"repos\":[\"myorg/web\"],\"contextSources\":[]}. Use this for uvx/hub launches with no file to mount. Either this or RELEASE_MCP_CONFIG is required."
24
+ },
25
+ {
26
+ "name": "RELEASE_MCP_CONFIG",
27
+ "description": "Absolute path to a config.json file (alternative to RELEASE_MCP_CONFIG_JSON). Used when mounting a real file, e.g. in Docker."
28
+ },
29
+ {
30
+ "name": "TOKEN",
31
+ "description": "Auth token for the provider (GitHub PAT / GitLab / Gitea token). Optional for public repos.",
32
+ "isSecret": true
33
+ },
34
+ {
35
+ "name": "PROVIDER",
36
+ "description": "Forge to read releases from: github | gitlab | gitea. Defaults to github."
37
+ },
38
+ {
39
+ "name": "BASE_URL",
40
+ "description": "API base URL — only for self-hosted GitLab or Gitea/Forgejo."
41
+ }
42
+ ]
43
+ }
44
+ ]
45
+ }
@@ -0,0 +1,422 @@
1
+ """
2
+ release-mcp — a small, generic MCP server for combining releases from several
3
+ repositories into product release notes.
4
+
5
+ Design:
6
+ * `repos` — the set of repos this server is allowed to read.
7
+ * `contextSources` — arbitrary URLs loaded as background context.
8
+ * Tools fetch / compare / bundle releases; Claude synthesizes the notes.
9
+
10
+ The forge (github | gitlab | gitea), base URL, and auth token come from the
11
+ environment (`PROVIDER` / `BASE_URL` / `TOKEN`), never from config.json.
12
+
13
+ Release fetching goes through a small Provider adapter, so adding a forge is a
14
+ contained change (normalize its release JSON into the common shape). Nothing
15
+ here is specific to any one architecture or to any single forge.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import os
23
+ from pathlib import Path
24
+ from typing import Any
25
+ from urllib.parse import quote
26
+
27
+ import httpx
28
+ from fastmcp import FastMCP
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # Providers
32
+ # --------------------------------------------------------------------------- #
33
+
34
+
35
+ class Provider:
36
+ """
37
+ Base adapter. A provider knows how to fetch releases for one repo and how to
38
+ normalize a raw release into the common shape:
39
+
40
+ {tag, name, published_at, prerelease, url, body}
41
+
42
+ `repo` is always 'owner/name' (GitLab: 'group/project', nesting allowed).
43
+ """
44
+
45
+ name = "base"
46
+ default_base = ""
47
+
48
+ def __init__(self, base_url: str = "", token: str = "") -> None:
49
+ self.base = (base_url or self.default_base).rstrip("/")
50
+ self.token = token
51
+
52
+ def headers(self) -> dict[str, str]:
53
+ return {}
54
+
55
+ def normalize(self, r: dict[str, Any]) -> dict[str, Any]:
56
+ raise NotImplementedError
57
+
58
+ async def list_releases(self, c: httpx.AsyncClient, repo: str, limit: int) -> list[dict]:
59
+ raise NotImplementedError
60
+
61
+ async def get_latest(self, c: httpx.AsyncClient, repo: str) -> dict:
62
+ rs = await self.list_releases(c, repo, 1)
63
+ if not rs:
64
+ raise ValueError(f"No releases found for '{repo}'")
65
+ return rs[0]
66
+
67
+ async def get_by_tag(self, c: httpx.AsyncClient, repo: str, tag: str) -> dict:
68
+ raise NotImplementedError
69
+
70
+
71
+ class GitHubProvider(Provider):
72
+ name = "github"
73
+ default_base = "https://api.github.com"
74
+
75
+ def headers(self) -> dict[str, str]:
76
+ h = {"Accept": "application/vnd.github+json"}
77
+ if self.token:
78
+ h["Authorization"] = f"Bearer {self.token}"
79
+ return h
80
+
81
+ def normalize(self, r: dict[str, Any]) -> dict[str, Any]:
82
+ return {
83
+ "tag": r.get("tag_name"),
84
+ "name": r.get("name"),
85
+ "published_at": r.get("published_at"),
86
+ "prerelease": r.get("prerelease"),
87
+ "url": r.get("html_url"),
88
+ "body": r.get("body") or "",
89
+ }
90
+
91
+ async def list_releases(self, c, repo, limit):
92
+ r = await c.get(
93
+ f"{self.base}/repos/{repo}/releases",
94
+ params={"per_page": limit},
95
+ headers=self.headers(),
96
+ )
97
+ r.raise_for_status()
98
+ return [self.normalize(x) for x in r.json()]
99
+
100
+ async def get_latest(self, c, repo):
101
+ r = await c.get(f"{self.base}/repos/{repo}/releases/latest", headers=self.headers())
102
+ r.raise_for_status()
103
+ return self.normalize(r.json())
104
+
105
+ async def get_by_tag(self, c, repo, tag):
106
+ r = await c.get(f"{self.base}/repos/{repo}/releases/tags/{tag}", headers=self.headers())
107
+ r.raise_for_status()
108
+ return self.normalize(r.json())
109
+
110
+
111
+ class GitLabProvider(Provider):
112
+ name = "gitlab"
113
+ default_base = "https://gitlab.com/api/v4"
114
+
115
+ def _pid(self, repo: str) -> str:
116
+ # GitLab addresses projects by URL-encoded full path (group/sub/project).
117
+ return quote(repo, safe="")
118
+
119
+ def headers(self) -> dict[str, str]:
120
+ return {"PRIVATE-TOKEN": self.token} if self.token else {}
121
+
122
+ def normalize(self, r: dict[str, Any]) -> dict[str, Any]:
123
+ links = r.get("_links") or {}
124
+ return {
125
+ "tag": r.get("tag_name"),
126
+ "name": r.get("name"),
127
+ "published_at": r.get("released_at"),
128
+ "prerelease": r.get("upcoming_release"),
129
+ "url": links.get("self"),
130
+ "body": r.get("description") or "",
131
+ }
132
+
133
+ async def list_releases(self, c, repo, limit):
134
+ r = await c.get(
135
+ f"{self.base}/projects/{self._pid(repo)}/releases",
136
+ params={"per_page": limit, "order_by": "released_at", "sort": "desc"},
137
+ headers=self.headers(),
138
+ )
139
+ r.raise_for_status()
140
+ return [self.normalize(x) for x in r.json()]
141
+
142
+ async def get_by_tag(self, c, repo, tag):
143
+ r = await c.get(
144
+ f"{self.base}/projects/{self._pid(repo)}/releases/{quote(tag, safe='')}",
145
+ headers=self.headers(),
146
+ )
147
+ r.raise_for_status()
148
+ return self.normalize(r.json())
149
+
150
+
151
+ class GiteaProvider(Provider):
152
+ """Gitea / Forgejo. Release shape is close to GitHub. Set `baseUrl`."""
153
+
154
+ name = "gitea"
155
+ default_base = "" # self-hosted — must be configured, e.g. https://git.example.com/api/v1
156
+
157
+ def headers(self) -> dict[str, str]:
158
+ h = {"Accept": "application/json"}
159
+ if self.token:
160
+ h["Authorization"] = f"token {self.token}"
161
+ return h
162
+
163
+ def normalize(self, r: dict[str, Any]) -> dict[str, Any]:
164
+ return {
165
+ "tag": r.get("tag_name"),
166
+ "name": r.get("name"),
167
+ "published_at": r.get("published_at"),
168
+ "prerelease": r.get("prerelease"),
169
+ "url": r.get("html_url") or r.get("url"),
170
+ "body": r.get("body") or "",
171
+ }
172
+
173
+ async def list_releases(self, c, repo, limit):
174
+ r = await c.get(
175
+ f"{self.base}/repos/{repo}/releases",
176
+ params={"limit": limit},
177
+ headers=self.headers(),
178
+ )
179
+ r.raise_for_status()
180
+ return [self.normalize(x) for x in r.json()]
181
+
182
+ async def get_by_tag(self, c, repo, tag):
183
+ r = await c.get(f"{self.base}/repos/{repo}/releases/tags/{tag}", headers=self.headers())
184
+ r.raise_for_status()
185
+ return self.normalize(r.json())
186
+
187
+
188
+ PROVIDERS = {p.name: p for p in (GitHubProvider, GitLabProvider, GiteaProvider)}
189
+
190
+
191
+ # --------------------------------------------------------------------------- #
192
+ # Config
193
+ # --------------------------------------------------------------------------- #
194
+
195
+ def load_config() -> dict[str, Any]:
196
+ """
197
+ Load the non-secret config (repos + contextSources) from, in order:
198
+
199
+ 1. `RELEASE_MCP_CONFIG_JSON` — the config as inline JSON. Best for `uvx`
200
+ and MCP hubs, where everything is passed as environment variables and
201
+ there is no file to mount.
202
+ 2. The file at `RELEASE_MCP_CONFIG` (default `./config.json`) — used by the
203
+ container, which bind-mounts a real config.
204
+
205
+ One of the two must be set; otherwise the server has nothing to read.
206
+ """
207
+ inline = os.environ.get("RELEASE_MCP_CONFIG_JSON")
208
+ if inline:
209
+ cfg = json.loads(inline)
210
+ else:
211
+ path = Path(os.environ.get("RELEASE_MCP_CONFIG", "config.json"))
212
+ if not path.exists():
213
+ raise FileNotFoundError(
214
+ f"No config found. Set RELEASE_MCP_CONFIG_JSON to inline JSON, or "
215
+ f"point RELEASE_MCP_CONFIG at a config file (looked for: {path}). "
216
+ f"Copy config.example.json to get started."
217
+ )
218
+ cfg = json.loads(path.read_text())
219
+ cfg.setdefault("repos", [])
220
+ cfg.setdefault("contextSources", [])
221
+ return cfg
222
+
223
+
224
+ CONFIG = load_config()
225
+ REPOS: list[str] = CONFIG["repos"]
226
+ CONTEXT_SOURCES: list[dict[str, Any]] = CONFIG["contextSources"]
227
+
228
+ # Provider / base URL / token all come from the environment. They are never
229
+ # stored in config.json, which holds only the (non-secret) repo set and context.
230
+ _provider_name = (os.environ.get("PROVIDER") or "github").lower()
231
+ _base_url = os.environ.get("BASE_URL") or ""
232
+ _token = os.environ.get("TOKEN", "")
233
+
234
+ if _provider_name not in PROVIDERS:
235
+ raise ValueError(f"Unknown provider '{_provider_name}'. Choose from: {', '.join(PROVIDERS)}")
236
+ PROVIDER: Provider = PROVIDERS[_provider_name](_base_url, _token)
237
+ if not PROVIDER.base:
238
+ raise ValueError(
239
+ f"Provider '{_provider_name}' requires a base URL (set the BASE_URL env var)."
240
+ )
241
+
242
+
243
+ def check_repo(repo: str) -> None:
244
+ """Keep the server scoped to configured repos."""
245
+ if REPOS and repo not in REPOS:
246
+ raise ValueError(
247
+ f"Repo '{repo}' is not in the configured scope. "
248
+ f"Allowed: {', '.join(REPOS) or '(none configured)'}"
249
+ )
250
+
251
+
252
+ # --------------------------------------------------------------------------- #
253
+ # Server
254
+ # --------------------------------------------------------------------------- #
255
+
256
+ INSTRUCTIONS = """
257
+ This server combines releases from several repositories into product release
258
+ notes.
259
+
260
+ Recommended flow when asked to assemble release notes:
261
+ 1. Call `get_context()` first to load any supplementary context the user has
262
+ configured (style guides, feature names, version info, anything).
263
+ 2. Use `list_repos`, `list_releases`, `get_latest_version`, `compare_releases`
264
+ to find the relevant releases.
265
+ 3. Call `gather_release_notes(selections=[...])` to bundle the raw notes.
266
+ 4. Synthesize a single product release note. Choose the best structure for the
267
+ content (by component, by change type, or a mix) and dedupe across repos.
268
+ """.strip()
269
+
270
+ mcp = FastMCP("release-notes", instructions=INSTRUCTIONS)
271
+
272
+
273
+ # --------------------------------------------------------------------------- #
274
+ # Tools — repos & releases
275
+ # --------------------------------------------------------------------------- #
276
+
277
+
278
+ @mcp.tool()
279
+ def list_repos() -> dict[str, Any]:
280
+ """List the repositories and provider this server is configured to read."""
281
+ return {"provider": PROVIDER.name, "repos": REPOS}
282
+
283
+
284
+ @mcp.tool()
285
+ async def list_releases(repo: str, limit: int = 10) -> list[dict[str, Any]]:
286
+ """List recent releases for one repo (newest first). `repo` is 'owner/name'."""
287
+ check_repo(repo)
288
+ async with httpx.AsyncClient(timeout=15) as c:
289
+ return await PROVIDER.list_releases(c, repo, limit)
290
+
291
+
292
+ @mcp.tool()
293
+ async def get_latest_version(repo: str) -> dict[str, Any]:
294
+ """Get the latest published release for one repo."""
295
+ check_repo(repo)
296
+ async with httpx.AsyncClient(timeout=15) as c:
297
+ return await PROVIDER.get_latest(c, repo)
298
+
299
+
300
+ @mcp.tool()
301
+ async def get_release(repo: str, tag: str) -> dict[str, Any]:
302
+ """Get the full release notes for a specific tag in one repo."""
303
+ check_repo(repo)
304
+ async with httpx.AsyncClient(timeout=15) as c:
305
+ return await PROVIDER.get_by_tag(c, repo, tag)
306
+
307
+
308
+ @mcp.tool()
309
+ async def compare_releases(repo: str, from_tag: str, to_tag: str) -> list[dict[str, Any]]:
310
+ """
311
+ Return every release in `repo` published after `from_tag` up to and including
312
+ `to_tag` (newest first) — useful when a service jumped several versions.
313
+ """
314
+ check_repo(repo)
315
+ async with httpx.AsyncClient(timeout=15) as c:
316
+ releases = await PROVIDER.list_releases(c, repo, 100)
317
+
318
+ tags = [x.get("tag") for x in releases]
319
+ if to_tag not in tags:
320
+ raise ValueError(f"to_tag '{to_tag}' not found in {repo}")
321
+ to_idx = tags.index(to_tag)
322
+ from_idx = tags.index(from_tag) if from_tag in tags else len(tags)
323
+ return releases[to_idx:from_idx]
324
+
325
+
326
+ @mcp.tool()
327
+ async def gather_release_notes(selections: list[dict[str, str]]) -> list[dict[str, Any]]:
328
+ """
329
+ Bundle raw release notes for an explicit list of selections so they can be
330
+ synthesized into a single product release.
331
+
332
+ `selections` is a list of {"repo": "owner/name", "tag": "v1.2.3"}.
333
+ Fetches all entries concurrently.
334
+ """
335
+ for s in selections:
336
+ check_repo(s["repo"])
337
+
338
+ async with httpx.AsyncClient(timeout=15) as c:
339
+
340
+ async def one(sel: dict[str, str]) -> dict[str, Any]:
341
+ rel = await PROVIDER.get_by_tag(c, sel["repo"], sel["tag"])
342
+ return {"repo": sel["repo"], **rel}
343
+
344
+ return await asyncio.gather(*(one(s) for s in selections))
345
+
346
+
347
+ # --------------------------------------------------------------------------- #
348
+ # Tools — context
349
+ # --------------------------------------------------------------------------- #
350
+
351
+
352
+ def _detect_and_parse(resp: httpx.Response, declared: str | None) -> Any:
353
+ """Auto-detect format (override with `declared`) and parse accordingly."""
354
+ fmt = declared
355
+ if not fmt:
356
+ ctype = resp.headers.get("content-type", "").lower()
357
+ url = str(resp.url).lower()
358
+ if "json" in ctype or url.endswith(".json"):
359
+ fmt = "json"
360
+ elif "yaml" in ctype or url.endswith((".yaml", ".yml")):
361
+ fmt = "yaml"
362
+ else:
363
+ fmt = "text"
364
+
365
+ if fmt == "json":
366
+ try:
367
+ return resp.json()
368
+ except Exception:
369
+ return resp.text
370
+ return resp.text
371
+
372
+
373
+ @mcp.tool()
374
+ async def get_context(name: str = "") -> list[dict[str, Any]]:
375
+ """
376
+ Load supplementary context the user configured in `contextSources`.
377
+
378
+ Call this first when assembling release notes. With no argument it loads all
379
+ sources; pass `name` to load just one. Format is auto-detected.
380
+ """
381
+ sources = CONTEXT_SOURCES
382
+ if name:
383
+ sources = [s for s in CONTEXT_SOURCES if s.get("name") == name]
384
+ if not sources:
385
+ raise ValueError(f"No context source named '{name}'")
386
+ if not sources:
387
+ return []
388
+
389
+ async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
390
+
391
+ async def one(src: dict[str, Any]) -> dict[str, Any]:
392
+ resp = await c.get(src["url"])
393
+ resp.raise_for_status()
394
+ return {
395
+ "name": src.get("name", src["url"]),
396
+ "url": src["url"],
397
+ "description": src.get("description", ""),
398
+ "content": _detect_and_parse(resp, src.get("format")),
399
+ }
400
+
401
+ return await asyncio.gather(*(one(s) for s in sources))
402
+
403
+
404
+ def main() -> None:
405
+ """Console-script entry point (`release-notes-mcp`, also used by `uvx`).
406
+
407
+ stdio (default) for a client-launched subprocess; http to run as a service.
408
+ """
409
+ transport = os.environ.get("MCP_TRANSPORT", "stdio")
410
+ if transport in ("http", "streamable-http", "sse"):
411
+ mcp.run(
412
+ transport=transport,
413
+ host=os.environ.get("MCP_HOST", "0.0.0.0"),
414
+ port=int(os.environ.get("MCP_PORT", "8000")),
415
+ )
416
+ else:
417
+ mcp.run()
418
+
419
+
420
+ if __name__ == "__main__":
421
+ main()
422
+