From 01162e21aad58a911bf4d25c721884dbf9277526 Mon Sep 17 00:00:00 2001 From: Masaru Nemoto <16267290+nemolize@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:23:46 +0900 Subject: [PATCH] feat(actions): add cancel_workflow_run and trigger_workflow_dispatch write tools (#8) Completes issue #8's write-tool tier for GitHub Actions. Both tools follow the rerun_workflow_run / rerun_failed_jobs conventions established in phase 4. cancel_workflow_run calls cancelWorkflowRun (202 Accepted / async); trigger_workflow_dispatch calls createWorkflowDispatch (204 No Content). WorkflowId accepts both filename strings and numeric IDs; inputs accepts string | number | boolean scalars to match GitHub's coercion rules. response.ts gains workflow_id and ref audit fields. README tool table and feature-comparison row updated to reflect full coverage. --- README.md | 36 ++++++------ src/mcp/response.ts | 7 ++- src/tools/actions.ts | 65 +++++++++++++++++++++ test/actions.test.js | 129 +++++++++++++++++++++++++++++++++++++++++ test/audit-log.test.js | 47 ++++++++++++++- 5 files changed, 264 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ed72253..a438173 100644 --- a/README.md +++ b/README.md @@ -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 + 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) | ❌ | ✅ | +| 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 + write (runs, jobs, workflows, job logs, artifacts, rerun all / failed jobs, cancel, dispatch) | ✅ | ✅ | +| 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. @@ -70,6 +70,8 @@ All tools respond in Markdown (not raw JSON) so the model can read them efficien | `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 | +| `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_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/mcp/response.ts b/src/mcp/response.ts index f02a10f..a4329a3 100644 --- a/src/mcp/response.ts +++ b/src/mcp/response.ts @@ -155,8 +155,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, `run_id` for -// workflow-run mutations, `thread_id` for GraphQL review-thread mutations that -// have no owner/repo at the call boundary). +// workflow-run mutations, `workflow_id` for a workflow dispatch, `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 = { @@ -168,6 +169,8 @@ export type WriteAuditFields = { issue_number?: number; pull_number?: number; run_id?: number; + workflow_id?: number | string; // trigger_workflow_dispatch + ref?: string; // trigger_workflow_dispatch file_count?: number; thread_id?: string; }; diff --git a/src/tools/actions.ts b/src/tools/actions.ts index e996cab..c5ab8a4 100644 --- a/src/tools/actions.ts +++ b/src/tools/actions.ts @@ -495,4 +495,69 @@ export const registerActionTools = (server: McpServer, client: OctokitFactory): ); }), ); + + server.registerTool( + "cancel_workflow_run", + { + description: + "Cancel an in-progress GitHub Actions workflow run, stopping its remaining jobs. Use when the user asks to stop / abort a running build. Mutates CI state. A run that has already finished returns 409 Conflict. Cancellation is asynchronous; returns a confirmation, poll `get_workflow_run` until the run's conclusion becomes `cancelled`.", + inputSchema: { + ...RepoTarget, + run_id: z.number().int().positive().describe("Workflow run ID to cancel."), + }, + }, + async ({ owner, repo, run_id }) => + wrapTool(async () => { + // cancelWorkflowRun answers 202 Accepted with an empty body — the + // cancellation is async, so there is no final state to render. Report + // the request; the caller reads the eventual `cancelled` conclusion via + // get_workflow_run. + const { headers } = await client().rest.actions.cancelWorkflowRun( + stripUndefined({ owner, repo, run_id }), + ); + logRateLimit(headers); + logWrite({ tool: "cancel_workflow_run", owner, repo, run_id }); + return text( + `# Cancellation requested\n\n- run \`${run_id}\` in ${owner}/${repo} requested to cancel (asynchronous)\n- poll \`get_workflow_run\` (run_id ${run_id}) until its conclusion is \`cancelled\``, + ); + }), + ); + + server.registerTool( + "trigger_workflow_dispatch", + { + description: + "Trigger a `workflow_dispatch`-enabled workflow manually on a given branch or tag, optionally with inputs. Use when the user asks to run / kick off a workflow by hand. Mutates CI state. The workflow's definition must declare a `workflow_dispatch` trigger, or GitHub returns 422. The dispatch endpoint returns no run reference; after triggering, find the new run with `list_workflow_runs` (filter by this `workflow_id` and `event: 'workflow_dispatch'`).", + inputSchema: { + ...RepoTarget, + workflow_id: WorkflowId.describe( + "Workflow filename (e.g. 'ci.yml') or numeric ID to dispatch.", + ), + ref: z.string().min(1).describe("Branch or tag the workflow runs against (e.g. 'main')."), + inputs: z + // GitHub accepts string / number / boolean input values (it coerces + // per the workflow's declared input types), so accept all three + // scalars rather than forcing every value to a string. + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional() + .describe( + "`workflow_dispatch` inputs as a string-keyed map of scalar values. Must match the workflow's declared inputs.", + ), + }, + }, + async ({ owner, repo, workflow_id, ref, inputs }) => + wrapTool(async () => { + // createWorkflowDispatch answers 204 No Content — GitHub returns no run + // reference for the dispatched run, so there is nothing to render but the + // request. The caller locates the new run via list_workflow_runs. + const { headers } = await client().rest.actions.createWorkflowDispatch( + stripUndefined({ owner, repo, workflow_id, ref, inputs }), + ); + logRateLimit(headers); + logWrite({ tool: "trigger_workflow_dispatch", owner, repo, workflow_id, ref }); + return text( + `# Workflow dispatch requested\n\n- workflow \`${workflow_id}\` in ${owner}/${repo} dispatched on \`${ref}\`\n- the dispatch endpoint returns no run ID; find the new run with \`list_workflow_runs\` (workflow_id \`${workflow_id}\`, event \`workflow_dispatch\`)`, + ); + }), + ); }; diff --git a/test/actions.test.js b/test/actions.test.js index 0fd0b6e..40ed421 100644 --- a/test/actions.test.js +++ b/test/actions.test.js @@ -17,6 +17,8 @@ const stubOctokit = (overrides, { request } = {}) => ({ getArtifact: async () => ({ data: {}, headers: {} }), reRunWorkflow: async () => ({ data: {}, headers: {} }), reRunWorkflowFailedJobs: async () => ({ data: {}, headers: {} }), + cancelWorkflowRun: async () => ({ data: {}, headers: {} }), + createWorkflowDispatch: async () => ({ data: {}, headers: {} }), ...overrides, }, }, @@ -690,6 +692,133 @@ describe("registerActionTools", () => { expect(result.content[0].text).toContain("HTTP 404"); }); + it("cancel_workflow_run returns confirmation with run ID and repo", async () => { + const { handlers, server } = captureHandlers(); + let capturedParams; + const octokit = stubOctokit({ + cancelWorkflowRun: async (params) => { + capturedParams = params; + return { data: {}, headers: {} }; + }, + }); + registerActionTools(server, () => octokit); + + const result = await invoke(handlers, "cancel_workflow_run", { + owner: "o", + repo: "r", + run_id: 777, + }); + const body = result.content[0].text; + expect(body).toContain("# Cancellation requested"); + expect(body).toContain("`777`"); + expect(body).toContain("o/r"); + expect(body).toContain("cancelled"); + expect(result.isError).toBeUndefined(); + expect(capturedParams).toMatchObject({ owner: "o", repo: "r", run_id: 777 }); + }); + + it("cancel_workflow_run surfaces Octokit errors via wrapTool (isError = true)", async () => { + const { handlers, server } = captureHandlers(); + const octokit = stubOctokit({ + cancelWorkflowRun: async () => { + const err = new Error("Conflict"); + err.status = 409; + throw err; + }, + }); + registerActionTools(server, () => octokit); + + const result = await invoke(handlers, "cancel_workflow_run", { + owner: "o", + repo: "r", + run_id: 1, + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("HTTP 409"); + }); + + it("trigger_workflow_dispatch returns confirmation and forwards workflow_id, ref, inputs", async () => { + const { handlers, server } = captureHandlers(); + let capturedParams; + const octokit = stubOctokit({ + createWorkflowDispatch: async (params) => { + capturedParams = params; + return { data: {}, headers: {} }; + }, + }); + registerActionTools(server, () => octokit); + + const result = await invoke(handlers, "trigger_workflow_dispatch", { + owner: "o", + repo: "r", + workflow_id: "ci.yml", + ref: "main", + inputs: { environment: "staging" }, + }); + const body = result.content[0].text; + expect(body).toContain("# Workflow dispatch requested"); + expect(body).toContain("`ci.yml`"); + expect(body).toContain("o/r"); + expect(body).toContain("`main`"); + expect(body).toContain("workflow_dispatch"); + expect(result.isError).toBeUndefined(); + expect(capturedParams).toMatchObject({ + owner: "o", + repo: "r", + workflow_id: "ci.yml", + ref: "main", + inputs: { environment: "staging" }, + }); + }); + + it("trigger_workflow_dispatch omits inputs when not provided", async () => { + const { handlers, server } = captureHandlers(); + let capturedParams; + const octokit = stubOctokit({ + createWorkflowDispatch: async (params) => { + capturedParams = params; + return { data: {}, headers: {} }; + }, + }); + registerActionTools(server, () => octokit); + + await invoke(handlers, "trigger_workflow_dispatch", { + owner: "o", + repo: "r", + workflow_id: 12345, + ref: "main", + }); + // stripUndefined drops the absent `inputs` so it never reaches Octokit. + expect(capturedParams).toMatchObject({ + owner: "o", + repo: "r", + workflow_id: 12345, + ref: "main", + }); + expect(capturedParams).not.toHaveProperty("inputs"); + }); + + it("trigger_workflow_dispatch surfaces Octokit errors via wrapTool (isError = true)", async () => { + const { handlers, server } = captureHandlers(); + const octokit = stubOctokit({ + createWorkflowDispatch: async () => { + const err = new Error("Unprocessable Entity"); + err.status = 422; + throw err; + }, + }); + registerActionTools(server, () => octokit); + + const result = await invoke(handlers, "trigger_workflow_dispatch", { + owner: "o", + repo: "r", + workflow_id: "ci.yml", + ref: "main", + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("HTTP 422"); + }); + it("get_workflow_run surfaces Octokit errors via wrapTool (isError = true)", async () => { const { handlers, server } = captureHandlers(); const octokit = stubOctokit({ diff --git a/test/audit-log.test.js b/test/audit-log.test.js index f850410..8062df4 100644 --- a/test/audit-log.test.js +++ b/test/audit-log.test.js @@ -196,6 +196,43 @@ describe("write tools emit audit logs", () => { ]); }); + it("cancel_workflow_run logs the run_id", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { handlers, server } = captureHandlers(); + const octokit = { + rest: { actions: { cancelWorkflowRun: async () => ({ data: {}, headers: {} }) } }, + }; + registerActionTools(server, () => octokit); + await invoke(handlers, "cancel_workflow_run", { owner: "o", repo: "r", run_id: 42 }); + expect(auditEntries(logSpy)).toEqual([ + { tool: "cancel_workflow_run", owner: "o", repo: "r", run_id: 42 }, + ]); + }); + + it("trigger_workflow_dispatch logs the workflow_id and ref", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { handlers, server } = captureHandlers(); + const octokit = { + rest: { actions: { createWorkflowDispatch: async () => ({ data: {}, headers: {} }) } }, + }; + registerActionTools(server, () => octokit); + await invoke(handlers, "trigger_workflow_dispatch", { + owner: "o", + repo: "r", + workflow_id: "ci.yml", + ref: "main", + }); + expect(auditEntries(logSpy)).toEqual([ + { + tool: "trigger_workflow_dispatch", + owner: "o", + repo: "r", + workflow_id: "ci.yml", + ref: "main", + }, + ]); + }); + it("does not emit an audit log when the write fails", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { handlers, server } = captureHandlers(); @@ -217,7 +254,7 @@ describe("write tools emit audit logs", () => { // A permissive Octokit stub whose every method resolves with a shape generous // enough for any single write handler. Each handler only reads a subset, so one -// fixture covers all 14 — this keeps the all-tools coverage table below from +// fixture covers all 18 — this keeps the all-tools coverage table below from // repeating a bespoke mock per tool. const wideOctokit = () => { const ok = (data) => async () => ({ data, headers: {} }); @@ -257,6 +294,8 @@ const wideOctokit = () => { actions: { reRunWorkflow: ok({}), reRunWorkflowFailedJobs: ok({}), + cancelWorkflowRun: ok({}), + createWorkflowDispatch: ok({}), }, }, }; @@ -320,6 +359,12 @@ const WRITE_TOOLS = [ ], [registerActionTools, "rerun_workflow_run", { owner: "o", repo: "r", run_id: 1 }], [registerActionTools, "rerun_failed_jobs", { owner: "o", repo: "r", run_id: 1 }], + [registerActionTools, "cancel_workflow_run", { owner: "o", repo: "r", run_id: 1 }], + [ + registerActionTools, + "trigger_workflow_dispatch", + { owner: "o", repo: "r", workflow_id: "ci.yml", ref: "main" }, + ], ]; describe("every write tool emits exactly one audit line tagged with its own name", () => {