From a73572aa91a2252b9d504194efb5ce38c7840e0a Mon Sep 17 00:00:00 2001 From: Masaru Nemoto <16267290+nemolize@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:09:59 +0900 Subject: [PATCH 1/2] feat(releases): add list_releases, get_release, and list_tags read tools Phase 1 (read) of the Releases & Tags feature area. Adds a new releases tool module covering release listing, single-release detail (by ID, tag, or latest), and tag listing, so the server can answer "what shipped?" questions without leaving MCP. Write tools (create / update / delete release) are deferred to a later PR. --- README.md | 5 +- src/tools.ts | 2 + src/tools/releases.ts | 153 +++++++++++++++++++++++++++++++ test/releases.test.js | 206 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 src/tools/releases.ts create mode 100644 test/releases.test.js diff --git a/README.md b/README.md index a438173..48489e5 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The table below compares coverage by **feature area** against the two most commo | **PR review thread resolve / unresolve** | ✅ | ❌ | ⚠️ `gh api graphql` only | | Commit history (list / show / compare) | ✅ | ✅ | ⚠️ `gh api` only | | Workflow / Actions (CI status, logs, rerun) | ✅ read + write (runs, jobs, workflows, job logs, artifacts, rerun all / failed jobs, cancel, dispatch) | ✅ | ✅ | -| Releases & tags | ❌ | ✅ | ✅ | +| Releases & tags | ⚠️ read only (`list_releases`, `get_release`, `list_tags`); create / update not yet | ✅ | ✅ | | Repo admin (create / fork / delete) | ❌ | ✅ | ✅ | | Security scanning (secret / code / Dependabot) | ❌ | ✅ | ⚠️ `gh api` only | | Local working-tree ops (clone, checkout, …) | ❌ — out of scope (remote-only) | ❌ | ✅ | @@ -72,6 +72,9 @@ All tools respond in Markdown (not raw JSON) so the model can read them efficien | `rerun_failed_jobs` | write | Re-run only the failed jobs of a run — new attempt; poll `get_workflow_run` for status | | `cancel_workflow_run` | write | Cancel an in-progress run (async); poll `get_workflow_run` until conclusion is `cancelled` | | `trigger_workflow_dispatch` | write | Manually dispatch a `workflow_dispatch` workflow on a ref with optional inputs | +| `list_releases` | read | Releases newest first (ID, name, tag, draft / prerelease / published state, date, author) | +| `get_release` | read | Single release detail + notes body — by `release_id`, by `tag`, or the latest when neither given | +| `list_tags` | read | Git tags (name, commit SHA) — for release metadata on a tag, use `get_release` with the tag | | `list_branches` | read | List branches in a repo (name, head SHA, protected flag) | | `create_branch` | write | Branch from a base (or the repo's default) | | `delete_branch` | write | Delete a branch (default branch refused) | diff --git a/src/tools.ts b/src/tools.ts index dfb15da..3118f9b 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -7,6 +7,7 @@ import { registerCommitTools } from "./tools/commits.js"; import { registerFileTools } from "./tools/files.js"; import { registerIssueTools } from "./tools/issues.js"; import { registerPullTools } from "./tools/pulls.js"; +import { registerReleaseTools } from "./tools/releases.js"; import { registerRepoTools } from "./tools/repos.js"; import { registerSearchTools } from "./tools/search.js"; @@ -23,4 +24,5 @@ export const registerTools = (server: McpServer, getAccessToken: () => string): registerPullTools(server, client); registerSearchTools(server, client); registerActionTools(server, client); + registerReleaseTools(server, client); }; diff --git a/src/tools/releases.ts b/src/tools/releases.ts new file mode 100644 index 0000000..e51ad82 --- /dev/null +++ b/src/tools/releases.ts @@ -0,0 +1,153 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { + errorResult, + logRateLimit, + restListHeader, + text, + truncate, + wrapTool, +} from "../mcp/response.js"; +import { stripUndefined } from "../utils.js"; +import type { OctokitFactory } from "./common.js"; +import { RepoTarget } from "./common.js"; + +// A release is exactly one of draft / prerelease / published — GitHub models +// the first two as boolean flags on top of the published state, so collapse +// the pair into the single human-facing label a "what's the latest release?" +// reader scans for. +const releaseState = (draft: boolean, prerelease: boolean): string => + draft ? "draft" : prerelease ? "prerelease" : "published"; + +export const registerReleaseTools = (server: McpServer, client: OctokitFactory): void => { + server.registerTool( + "list_releases", + { + description: + "List a repository's releases, newest first (one line per release: ID, name, tag, draft / prerelease / published state, publish date, author). Use when the user asks what releases exist, what shipped recently, or to find a `release_id` / tag for `get_release`. Drafts are included only when the token has push access.", + inputSchema: { + ...RepoTarget, + per_page: z.number().int().min(1).max(100).optional().default(20), + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (1-indexed). Defaults to 1."), + }, + }, + async ({ owner, repo, per_page, page }) => + wrapTool(async () => { + const { data, headers } = await client().rest.repos.listReleases( + stripUndefined({ owner, repo, per_page, page }), + ); + logRateLimit(headers); + if (data.length === 0) return text("(no releases found)"); + const lines = data.map((r) => { + const state = releaseState(r.draft, r.prerelease); + const name = r.name != null && r.name.length > 0 ? r.name : r.tag_name; + const when = r.published_at ?? "unpublished"; + const author = r.author?.login ?? "(unknown)"; + return `- \`${r.id}\` **${name}** (\`${r.tag_name}\`) — ${state}, ${when}, by ${author}`; + }); + const hasMore = (headers.link ?? "").includes('rel="next"'); + const header = restListHeader({ + title: "Releases", + count: data.length, + page, + hasMore, + }); + return text(truncate(`${header}\n\n${lines.join("\n")}`)); + }), + ); + + server.registerTool( + "get_release", + { + description: + "Fetch a single release's detail — name, tag, draft / prerelease / published state, target commitish, timestamps, author, asset count, URL, and the release notes body (truncated at the response cap). Look up by `release_id`, by `tag`, or pass neither for the latest release. Note: the latest-release lookup follows GitHub semantics and never returns drafts or prereleases — use `release_id` or `tag` for those.", + inputSchema: { + ...RepoTarget, + release_id: z + .number() + .int() + .positive() + .optional() + .describe("Release ID (from `list_releases`). Mutually exclusive with `tag`."), + tag: z + .string() + .min(1) + .optional() + .describe("Tag name (e.g. 'v1.2.0'). Mutually exclusive with `release_id`."), + }, + }, + async ({ owner, repo, release_id, tag }) => + wrapTool(async () => { + if (release_id != null && tag != null) { + return errorResult("Pass either `release_id` or `tag`, not both."); + } + // Three distinct endpoints behind one lookup: by ID, by tag, or the + // repo's latest release. All return the same release shape. + const { data, headers } = + release_id != null + ? await client().rest.repos.getRelease({ owner, repo, release_id }) + : tag != null + ? await client().rest.repos.getReleaseByTag({ owner, repo, tag }) + : await client().rest.repos.getLatestRelease({ owner, repo }); + logRateLimit(headers); + const state = releaseState(data.draft, data.prerelease); + const name = data.name != null && data.name.length > 0 ? data.name : data.tag_name; + const lines = [ + `# Release \`${data.id}\` in ${owner}/${repo}`, + "", + `> ${name} — ${state}`, + "", + `- tag: \`${data.tag_name}\` (target \`${data.target_commitish}\`)`, + `- published: ${data.published_at ?? "(unpublished)"}`, + `- created: ${data.created_at}`, + `- author: ${data.author?.login ?? "(unknown)"}`, + `- assets: ${data.assets.length}`, + `- ${data.html_url}`, + ]; + const body = data.body; + const notes = body != null && body.length > 0 ? `\n\n## Notes\n\n${body}` : ""; + return text(truncate(`${lines.join("\n")}${notes}`)); + }), + ); + + server.registerTool( + "list_tags", + { + description: + "List a repository's git tags (one line per tag: name, commit SHA). Use when the user asks what tags exist or wants a tag's commit. Tags are ordered as GitHub returns them (roughly reverse creation order). For release metadata attached to a tag, use `get_release` with the tag name instead.", + inputSchema: { + ...RepoTarget, + per_page: z.number().int().min(1).max(100).optional().default(30), + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (1-indexed). Defaults to 1."), + }, + }, + async ({ owner, repo, per_page, page }) => + wrapTool(async () => { + const { data, headers } = await client().rest.repos.listTags( + stripUndefined({ owner, repo, per_page, page }), + ); + logRateLimit(headers); + if (data.length === 0) return text("(no tags found)"); + const lines = data.map((t) => `- \`${t.name}\` @ \`${t.commit.sha.slice(0, 7)}\``); + const hasMore = (headers.link ?? "").includes('rel="next"'); + const header = restListHeader({ + title: "Tags", + count: data.length, + page, + hasMore, + }); + return text(truncate(`${header}\n\n${lines.join("\n")}`)); + }), + ); +}; diff --git a/test/releases.test.js b/test/releases.test.js new file mode 100644 index 0000000..ce9f266 --- /dev/null +++ b/test/releases.test.js @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; + +import { registerReleaseTools } from "../src/tools/releases.js"; +import { captureHandlers, invoke } from "./_helpers/tools.js"; + +const stubOctokit = (overrides) => ({ + rest: { + repos: { + listReleases: async () => ({ data: [], headers: {} }), + getRelease: async () => ({ data: {}, headers: {} }), + getReleaseByTag: async () => ({ data: {}, headers: {} }), + getLatestRelease: async () => ({ data: {}, headers: {} }), + listTags: async () => ({ data: [], headers: {} }), + ...overrides, + }, + }, +}); + +const sampleRelease = (overrides = {}) => ({ + id: 4242, + tag_name: "v1.2.0", + name: "Spring release", + draft: false, + prerelease: false, + target_commitish: "main", + published_at: "2026-06-01T00:00:00Z", + created_at: "2026-05-30T00:00:00Z", + author: { login: "alice" }, + assets: [], + html_url: "https://github.com/o/r/releases/tag/v1.2.0", + body: "Highlights of the release.", + ...overrides, +}); + +describe("registerReleaseTools", () => { + it("list_releases renders ID, name, tag, state, date, author", async () => { + const { handlers, server } = captureHandlers(); + const octokit = stubOctokit({ + listReleases: async () => ({ data: [sampleRelease()], headers: {} }), + }); + registerReleaseTools(server, () => octokit); + + const result = await invoke(handlers, "list_releases", { owner: "o", repo: "r" }); + const body = result.content[0].text; + expect(body).toContain("# Releases (1)"); + expect(body).toContain( + "`4242` **Spring release** (`v1.2.0`) — published, 2026-06-01T00:00:00Z, by alice", + ); + expect(result.isError).toBeUndefined(); + }); + + it("list_releases labels drafts and prereleases, falling back to the tag for an unnamed release", async () => { + const { handlers, server } = captureHandlers(); + const octokit = stubOctokit({ + listReleases: async () => ({ + data: [ + sampleRelease({ id: 1, draft: true, name: null, published_at: null }), + sampleRelease({ id: 2, prerelease: true, tag_name: "v2.0.0-rc.1" }), + ], + headers: {}, + }), + }); + registerReleaseTools(server, () => octokit); + + const result = await invoke(handlers, "list_releases", { owner: "o", repo: "r" }); + const body = result.content[0].text; + expect(body).toContain("`1` **v1.2.0** (`v1.2.0`) — draft, unpublished, by alice"); + expect(body).toContain("`2` **Spring release** (`v2.0.0-rc.1`) — prerelease"); + }); + + it("list_releases shows a pagination hint when a next link is present", async () => { + const { handlers, server } = captureHandlers(); + const octokit = stubOctokit({ + listReleases: async () => ({ + data: [sampleRelease()], + headers: { link: '; rel="next"' }, + }), + }); + registerReleaseTools(server, () => octokit); + + const result = await invoke(handlers, "list_releases", { owner: "o", repo: "r", page: 1 }); + expect(result.content[0].text).toContain("page 1, 1 shown; more available"); + }); + + it("list_releases reports an empty result", async () => { + const { handlers, server } = captureHandlers(); + registerReleaseTools(server, () => stubOctokit({})); + + const result = await invoke(handlers, "list_releases", { owner: "o", repo: "r" }); + expect(result.content[0].text).toBe("(no releases found)"); + }); + + it("get_release looks up by release_id and renders detail + notes", async () => { + const { handlers, server } = captureHandlers(); + let captured; + const octokit = stubOctokit({ + getRelease: async (params) => { + captured = params; + return { data: sampleRelease(), headers: {} }; + }, + }); + registerReleaseTools(server, () => octokit); + + const result = await invoke(handlers, "get_release", { + owner: "o", + repo: "r", + release_id: 4242, + }); + expect(captured).toMatchObject({ owner: "o", repo: "r", release_id: 4242 }); + const body = result.content[0].text; + expect(body).toContain("# Release `4242` in o/r"); + expect(body).toContain("> Spring release — published"); + expect(body).toContain("- tag: `v1.2.0` (target `main`)"); + expect(body).toContain("- author: alice"); + expect(body).toContain("- assets: 0"); + expect(body).toContain("## Notes\n\nHighlights of the release."); + }); + + it("get_release looks up by tag when tag is given", async () => { + const { handlers, server } = captureHandlers(); + let captured; + const octokit = stubOctokit({ + getReleaseByTag: async (params) => { + captured = params; + return { data: sampleRelease(), headers: {} }; + }, + }); + registerReleaseTools(server, () => octokit); + + await invoke(handlers, "get_release", { owner: "o", repo: "r", tag: "v1.2.0" }); + expect(captured).toMatchObject({ owner: "o", repo: "r", tag: "v1.2.0" }); + }); + + it("get_release falls back to the latest release when neither release_id nor tag is given", async () => { + const { handlers, server } = captureHandlers(); + let latestCalled = false; + const octokit = stubOctokit({ + getLatestRelease: async () => { + latestCalled = true; + return { data: sampleRelease(), headers: {} }; + }, + }); + registerReleaseTools(server, () => octokit); + + await invoke(handlers, "get_release", { owner: "o", repo: "r" }); + expect(latestCalled).toBe(true); + }); + + it("get_release rejects release_id and tag together", async () => { + const { handlers, server } = captureHandlers(); + registerReleaseTools(server, () => stubOctokit({})); + + const result = await invoke(handlers, "get_release", { + owner: "o", + repo: "r", + release_id: 1, + tag: "v1.0.0", + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("either `release_id` or `tag`, not both"); + }); + + it("get_release omits the Notes section when the body is empty", async () => { + const { handlers, server } = captureHandlers(); + const octokit = stubOctokit({ + getRelease: async () => ({ data: sampleRelease({ body: null }), headers: {} }), + }); + registerReleaseTools(server, () => octokit); + + const result = await invoke(handlers, "get_release", { + owner: "o", + repo: "r", + release_id: 4242, + }); + expect(result.content[0].text).not.toContain("## Notes"); + }); + + it("list_tags renders tag name and short SHA", async () => { + const { handlers, server } = captureHandlers(); + const octokit = stubOctokit({ + listTags: async () => ({ + data: [ + { name: "v1.2.0", commit: { sha: "abcdef1234567890" } }, + { name: "v1.1.0", commit: { sha: "feedface00001111" } }, + ], + headers: {}, + }), + }); + registerReleaseTools(server, () => octokit); + + const result = await invoke(handlers, "list_tags", { owner: "o", repo: "r" }); + const body = result.content[0].text; + expect(body).toContain("# Tags (2)"); + expect(body).toContain("- `v1.2.0` @ `abcdef1`"); + expect(body).toContain("- `v1.1.0` @ `feedfac`"); + expect(result.isError).toBeUndefined(); + }); + + it("list_tags reports an empty result", async () => { + const { handlers, server } = captureHandlers(); + registerReleaseTools(server, () => stubOctokit({})); + + const result = await invoke(handlers, "list_tags", { owner: "o", repo: "r" }); + expect(result.content[0].text).toBe("(no tags found)"); + }); +}); From 1e43301e89f34cc113d95822ccf855c01e4f65de Mon Sep 17 00:00:00 2001 From: Masaru Nemoto <16267290+nemolize@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:23:20 +0900 Subject: [PATCH 2/2] fix(releases): render "draft prerelease" when both flags are set GitHub's `draft` and `prerelease` are independent boolean flags, so a release can be both simultaneously. The previous ternary treated `draft` as dominant and hid the prerelease facet. Expand `releaseState` to return "draft prerelease" when both are true. --- src/tools/releases.ts | 16 +++++++++++----- test/releases.test.js | 2 ++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/tools/releases.ts b/src/tools/releases.ts index e51ad82..c56cf56 100644 --- a/src/tools/releases.ts +++ b/src/tools/releases.ts @@ -13,12 +13,18 @@ import { stripUndefined } from "../utils.js"; import type { OctokitFactory } from "./common.js"; import { RepoTarget } from "./common.js"; -// A release is exactly one of draft / prerelease / published — GitHub models -// the first two as boolean flags on top of the published state, so collapse -// the pair into the single human-facing label a "what's the latest release?" -// reader scans for. +// GitHub models draft and prerelease as independent boolean flags, so a +// release can be both at once (an unpublished draft of a prerelease). +// Collapse the pair into the single human-facing label a "what's the latest +// release?" reader scans for, keeping both facets visible when they combine. const releaseState = (draft: boolean, prerelease: boolean): string => - draft ? "draft" : prerelease ? "prerelease" : "published"; + draft && prerelease + ? "draft prerelease" + : draft + ? "draft" + : prerelease + ? "prerelease" + : "published"; export const registerReleaseTools = (server: McpServer, client: OctokitFactory): void => { server.registerTool( diff --git a/test/releases.test.js b/test/releases.test.js index ce9f266..eeb17d2 100644 --- a/test/releases.test.js +++ b/test/releases.test.js @@ -56,6 +56,7 @@ describe("registerReleaseTools", () => { data: [ sampleRelease({ id: 1, draft: true, name: null, published_at: null }), sampleRelease({ id: 2, prerelease: true, tag_name: "v2.0.0-rc.1" }), + sampleRelease({ id: 3, draft: true, prerelease: true, published_at: null }), ], headers: {}, }), @@ -66,6 +67,7 @@ describe("registerReleaseTools", () => { const body = result.content[0].text; expect(body).toContain("`1` **v1.2.0** (`v1.2.0`) — draft, unpublished, by alice"); expect(body).toContain("`2` **Spring release** (`v2.0.0-rc.1`) — prerelease"); + expect(body).toContain("`3` **Spring release** (`v1.2.0`) — draft prerelease, unpublished"); }); it("list_releases shows a pagination hint when a next link is present", async () => {