release-notes-mcp 1.0.0__py3-none-any.whl
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,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,5 @@
|
|
|
1
|
+
server.py,sha256=zGtBrjXh-hE4BHM0SSoM6w7gpZN7-yEx6ahGdDoSpsk,14677
|
|
2
|
+
release_notes_mcp-1.0.0.dist-info/METADATA,sha256=gYigf42UzAYKtPB4lakWfsfPL27bEBeHEZwi1RcX6Tc,5750
|
|
3
|
+
release_notes_mcp-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
4
|
+
release_notes_mcp-1.0.0.dist-info/entry_points.txt,sha256=A33daAc0zQGgLe5qgvImT0H9JDhFYBmkVE6NOmfkLAg,50
|
|
5
|
+
release_notes_mcp-1.0.0.dist-info/RECORD,,
|
server.py
ADDED
|
@@ -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
|
+
|