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.
- release_notes_mcp-1.0.0/.dockerignore +32 -0
- release_notes_mcp-1.0.0/.env.example +23 -0
- release_notes_mcp-1.0.0/.github/workflows/release.yml +99 -0
- release_notes_mcp-1.0.0/.gitignore +23 -0
- release_notes_mcp-1.0.0/Dockerfile +29 -0
- release_notes_mcp-1.0.0/PKG-INFO +163 -0
- release_notes_mcp-1.0.0/README.md +151 -0
- release_notes_mcp-1.0.0/compose.yaml +36 -0
- release_notes_mcp-1.0.0/config.example.json +14 -0
- release_notes_mcp-1.0.0/pyproject.toml +30 -0
- release_notes_mcp-1.0.0/requirements.txt +2 -0
- release_notes_mcp-1.0.0/server.json +45 -0
- release_notes_mcp-1.0.0/server.py +422 -0
|
@@ -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,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
|
+
|