Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ❌ | ✅ |
Expand Down Expand Up @@ -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) |
Expand Down
2 changes: 2 additions & 0 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -23,4 +24,5 @@ export const registerTools = (server: McpServer, getAccessToken: () => string):
registerPullTools(server, client);
registerSearchTools(server, client);
registerActionTools(server, client);
registerReleaseTools(server, client);
};
159 changes: 159 additions & 0 deletions src/tools/releases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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";

// 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 && prerelease
? "draft prerelease"
: 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")}`));
}),
);
};
Loading