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
36 changes: 19 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,23 @@ Several GitHub MCP servers exist, alongside GitHub's own `gh` CLI. This server's

The table below compares coverage by **feature area** against the two most common alternatives. It stays deliberately coarse-grained — the [tool table](#whats-included) is the source of truth for which tools exist right now, and `gh`'s coverage is documented in the [GitHub CLI manual](https://cli.github.com/manual/). Legend: ✅ first-class · ⚠️ only via a lower-level escape hatch (`gh api`, local `git`) · ❌ absent.

| Feature area | This server | Official `mcp__github__*` | `gh` CLI |
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -------------------------------- |
| Repo metadata & discovery | ✅ | ✅ | ✅ |
| Issue read / triage / lifecycle (labels, assignees, comments) | ✅ | ✅ | ✅ |
| Code & repo search | ✅ | ✅ | ✅ |
| File content read + remote commit (single & multi-file) | ✅ | ✅ | ⚠️ `gh api` only |
| Branch list / create / delete | ✅ | ✅ | ⚠️ `gh api` / local `git` |
| PR open / diff / request reviewers | ✅ | ✅ | ✅ |
| PR read detail / merge / update lifecycle | ✅ | ✅ | ✅ |
| PR reviews & review comments (read + reply) | ⚠️ read only (`list_pr_reviews` + threads); reply not yet | ✅ | ⚠️ partial (`gh api` for inline) |
| **PR review thread resolve / unresolve** | ✅ | ❌ | ⚠️ `gh api graphql` only |
| Commit history (list / show / compare) | ✅ | ✅ | ⚠️ `gh api` only |
| Workflow / Actions (CI status, logs, rerun) | ⚠️ read only (runs, jobs, workflows, job logs, artifacts); rerun / cancel / dispatch planned ([#8](https://github.com/nemolize/remote-mcp-github/issues/8)) | ✅ | ✅ |
| Releases & tags | ❌ | ✅ | ✅ |
| Repo admin (create / fork / delete) | ❌ | ✅ | ✅ |
| Security scanning (secret / code / Dependabot) | ❌ | ✅ | ⚠️ `gh api` only |
| Local working-tree ops (clone, checkout, …) | ❌ — out of scope (remote-only) | ❌ | ✅ |
| Feature area | This server | Official `mcp__github__*` | `gh` CLI |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -------------------------------- |
| Repo metadata & discovery | ✅ | ✅ | ✅ |
| Issue read / triage / lifecycle (labels, assignees, comments) | ✅ | ✅ | ✅ |
| Code & repo search | ✅ | ✅ | ✅ |
| File content read + remote commit (single & multi-file) | ✅ | ✅ | ⚠️ `gh api` only |
| Branch list / create / delete | ✅ | ✅ | ⚠️ `gh api` / local `git` |
| PR open / diff / request reviewers | ✅ | ✅ | ✅ |
| PR read detail / merge / update lifecycle | ✅ | ✅ | ✅ |
| PR reviews & review comments (read + reply) | ⚠️ read only (`list_pr_reviews` + threads); reply not yet | ✅ | ⚠️ partial (`gh api` for inline) |
| **PR review thread resolve / unresolve** | ✅ | ❌ | ⚠️ `gh api graphql` only |
| Commit history (list / show / compare) | ✅ | ✅ | ⚠️ `gh api` only |
| Workflow / Actions (CI status, logs, rerun) | ⚠️ read + rerun (runs, jobs, workflows, job logs, artifacts, rerun all / failed jobs); cancel / dispatch planned ([#8](https://github.com/nemolize/remote-mcp-github/issues/8)) | ✅ | ✅ |
| Releases & tags | ❌ | ✅ | ✅ |
| Repo admin (create / fork / delete) | ❌ | ✅ | ✅ |
| Security scanning (secret / code / Dependabot) | ❌ | ✅ | ⚠️ `gh api` only |
| Local working-tree ops (clone, checkout, …) | ❌ — out of scope (remote-only) | ❌ | ✅ |

Also outside this server's scope today: notifications, Copilot delegation, gists, and projects — reach for the official server or `gh` for those.

Expand Down Expand Up @@ -68,6 +68,8 @@ All tools respond in Markdown (not raw JSON) so the model can read them efficien
| `get_workflow_run_logs` | read | Full run log archive download URL (zip of all jobs; short-lived URL, metadata only, no download) |
| `list_workflow_run_artifacts` | read | Artifacts produced by a run (ID, name, size, expiry) — the "what did the build produce?" lookup |
| `get_artifact` | read | Single artifact metadata + archive download URL (zip; metadata only, no download / unzip) |
| `rerun_workflow_run` | write | Re-run an entire workflow run (all jobs) — new attempt; poll `get_workflow_run` for status |
| `rerun_failed_jobs` | write | Re-run only the failed jobs of a run — new attempt; poll `get_workflow_run` for status |
| `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
6 changes: 4 additions & 2 deletions src/mcp/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,9 @@ export const logRateLimit = (

// Fields a write tool reports about the mutation it performed. `tool` is always
// present; the rest are tool-specific (e.g. `owner` + `repo` + `branch` +
// `path` for file commits, `issue_number` for issue/PR edits, `thread_id` for
// GraphQL review-thread mutations that have no owner/repo at the call boundary).
// `path` for file commits, `issue_number` for issue/PR edits, `run_id` for
// workflow-run mutations, `thread_id` for GraphQL review-thread mutations that
// have no owner/repo at the call boundary).
// `null`/undefined values are dropped so the emitted line only carries what the
// call touched.
export type WriteAuditFields = {
Expand All @@ -166,6 +167,7 @@ export type WriteAuditFields = {
path?: string;
issue_number?: number;
pull_number?: number;
run_id?: number;
file_count?: number;
thread_id?: string;
};
Expand Down
61 changes: 61 additions & 0 deletions src/tools/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod";

import {
logRateLimit,
logWrite,
MAX_RESPONSE_CHARS,
restListHeader,
text,
Expand Down Expand Up @@ -434,4 +435,64 @@ export const registerActionTools = (server: McpServer, client: OctokitFactory):
return text(truncate(lines.join("\n")));
}),
);
server.registerTool(
"rerun_workflow_run",
{
description:
"Re-run an entire workflow run (all jobs), creating a new attempt. Use when the user asks to retry / re-run a whole run — typically after a transient failure. Mutates CI state. For retrying only the jobs that failed, prefer `rerun_failed_jobs`. Returns a confirmation; poll `get_workflow_run` for the new attempt's status.",
inputSchema: {
...RepoTarget,
run_id: z.number().int().positive().describe("Workflow run ID to re-run."),
enable_debug_logging: z
.boolean()
.optional()
.describe("Re-run with step debug logging enabled (GitHub's `enable_debug_logging`)."),
},
},
async ({ owner, repo, run_id, enable_debug_logging }) =>
wrapTool(async () => {
// reRunWorkflow answers 201 with an empty body, so there is no run
// detail to render — only the new attempt's eventual state, which the
// caller reads via get_workflow_run. Report the action, not a body.
const { headers } = await client().rest.actions.reRunWorkflow(
stripUndefined({ owner, repo, run_id, enable_debug_logging }),
);
logRateLimit(headers);
logWrite({ tool: "rerun_workflow_run", owner, repo, run_id });
return text(
`# Re-run requested\n\n- run \`${run_id}\` in ${owner}/${repo} queued for a new attempt (all jobs)\n- check progress with \`get_workflow_run\` (run_id ${run_id})`,
);
}),
);

server.registerTool(
"rerun_failed_jobs",
{
description:
"Re-run only the failed jobs of a workflow run (plus any jobs that depend on them), creating a new attempt. Use when the user asks to retry just the failures rather than the whole run. Mutates CI state. For re-running the entire run, use `rerun_workflow_run`. Returns a confirmation; poll `get_workflow_run` for the new attempt's status.",
inputSchema: {
...RepoTarget,
run_id: z
.number()
.int()
.positive()
.describe("Workflow run ID whose failed jobs to re-run."),
enable_debug_logging: z
.boolean()
.optional()
.describe("Re-run with step debug logging enabled (GitHub's `enable_debug_logging`)."),
},
},
async ({ owner, repo, run_id, enable_debug_logging }) =>
wrapTool(async () => {
const { headers } = await client().rest.actions.reRunWorkflowFailedJobs(
stripUndefined({ owner, repo, run_id, enable_debug_logging }),
);
logRateLimit(headers);
logWrite({ tool: "rerun_failed_jobs", owner, repo, run_id });
return text(
`# Re-run requested (failed jobs)\n\n- failed jobs of run \`${run_id}\` in ${owner}/${repo} queued for a new attempt\n- check progress with \`get_workflow_run\` (run_id ${run_id})`,
);
}),
);
};
102 changes: 102 additions & 0 deletions test/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const stubOctokit = (overrides, { request } = {}) => ({
downloadJobLogsForWorkflowRun: async () => ({ data: "", headers: {} }),
listWorkflowRunArtifacts: async () => ({ data: { artifacts: [] }, headers: {} }),
getArtifact: async () => ({ data: {}, headers: {} }),
reRunWorkflow: async () => ({ data: {}, headers: {} }),
reRunWorkflowFailedJobs: async () => ({ data: {}, headers: {} }),
...overrides,
},
},
Expand Down Expand Up @@ -588,6 +590,106 @@ describe("registerActionTools", () => {
expect(body).not.toContain("undefined");
});

it("rerun_workflow_run returns confirmation with run ID and repo", async () => {
const { handlers, server } = captureHandlers();
let capturedParams;
const octokit = stubOctokit({
reRunWorkflow: async (params) => {
capturedParams = params;
return { data: {}, headers: {} };
},
});
registerActionTools(server, () => octokit);

const result = await invoke(handlers, "rerun_workflow_run", {
owner: "o",
repo: "r",
run_id: 999,
enable_debug_logging: true,
});
const body = result.content[0].text;
expect(body).toContain("# Re-run requested");
expect(body).toContain("`999`");
expect(body).toContain("o/r");
expect(body).toContain("all jobs");
expect(result.isError).toBeUndefined();
// Verify the correct Octokit method was called with the right params
expect(capturedParams).toMatchObject({
owner: "o",
repo: "r",
run_id: 999,
enable_debug_logging: true,
});
});

it("rerun_workflow_run surfaces Octokit errors via wrapTool (isError = true)", async () => {
const { handlers, server } = captureHandlers();
const octokit = stubOctokit({
reRunWorkflow: async () => {
const err = new Error("Forbidden");
err.status = 403;
throw err;
},
});
registerActionTools(server, () => octokit);

const result = await invoke(handlers, "rerun_workflow_run", {
owner: "o",
repo: "r",
run_id: 1,
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("HTTP 403");
});

it("rerun_failed_jobs returns confirmation with run ID and repo", async () => {
const { handlers, server } = captureHandlers();
let capturedParams;
const octokit = stubOctokit({
reRunWorkflowFailedJobs: async (params) => {
capturedParams = params;
return { data: {}, headers: {} };
},
});
registerActionTools(server, () => octokit);

const result = await invoke(handlers, "rerun_failed_jobs", {
owner: "o",
repo: "r",
run_id: 888,
enable_debug_logging: false,
});
const body = result.content[0].text;
expect(body).toContain("# Re-run requested (failed jobs)");
expect(body).toContain("`888`");
expect(body).toContain("o/r");
expect(result.isError).toBeUndefined();
// Verify the correct Octokit method was called with the right params
// enable_debug_logging: false is stripped by stripUndefined (false is falsy but not
// undefined — stripUndefined only removes undefined values, so false passes through)
expect(capturedParams).toMatchObject({ owner: "o", repo: "r", run_id: 888 });
});

it("rerun_failed_jobs surfaces Octokit errors via wrapTool (isError = true)", async () => {
const { handlers, server } = captureHandlers();
const octokit = stubOctokit({
reRunWorkflowFailedJobs: async () => {
const err = new Error("Not Found");
err.status = 404;
throw err;
},
});
registerActionTools(server, () => octokit);

const result = await invoke(handlers, "rerun_failed_jobs", {
owner: "o",
repo: "r",
run_id: 1,
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("HTTP 404");
});

it("get_workflow_run surfaces Octokit errors via wrapTool (isError = true)", async () => {
const { handlers, server } = captureHandlers();
const octokit = stubOctokit({
Expand Down
Loading