From 99a4f802d4fcfd1f7c241f4eae57dcd2ef0bb64e Mon Sep 17 00:00:00 2001 From: Masaru Nemoto <16267290+nemolize@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:45:25 +0900 Subject: [PATCH] feat(actions): add get_workflow_run_logs read tool (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns the pre-signed archive download URL via `redirect: "manual"` so the zip body is never fetched into the Worker — mirrors the metadata-only convention established by get_artifact. --- README.md | 1 + src/tools/actions.ts | 43 ++++++++++++++++++++++++++++++++++ test/actions.test.js | 56 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dc7ec6a..6791cbf 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ All tools respond in Markdown (not raw JSON) so the model can read them efficien | `list_workflow_run_jobs` | read | Jobs of a run with per-step status — the "what failed?" lookup | | `list_workflows` | read | Workflows defined in the repo (ID, name, state, path) — discover pipelines / find a `workflow_id` | | `get_job_logs` | read | Plain-text logs of a single job, tail-truncated — the "why did it fail?" lookup after the job list | +| `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) | | `list_branches` | read | List branches in a repo (name, head SHA, protected flag) | diff --git a/src/tools/actions.ts b/src/tools/actions.ts index 17fb543..8cbed12 100644 --- a/src/tools/actions.ts +++ b/src/tools/actions.ts @@ -301,6 +301,49 @@ export const registerActionTools = (server: McpServer, client: OctokitFactory): }), ); + server.registerTool( + "get_workflow_run_logs", + { + description: + "Get the download URL for a workflow run's full log archive (a zip of every job's logs). Use when the user wants the complete run logs as a file. The URL is a pre-signed storage link, so it is short-lived (expires within minutes) and fetching it needs no authentication (unlike `get_artifact`'s URL, which is an API endpoint). For reading a specific failing job's log inline, prefer `get_job_logs` — this tool returns a URL only and never downloads or unpacks the zip. Logs for an expired run return 410 Gone; an account without sufficient repository access returns 403.", + inputSchema: { + ...RepoTarget, + run_id: z.number().int().positive().describe("Workflow run ID."), + }, + }, + async ({ owner, repo, run_id }) => + wrapTool(async () => { + // Raw `request` (not a typed `client().rest.actions.*` call like every + // other handler here) because the pinned octokit has no typed method for + // this endpoint — `get_job_logs` uses the typed `downloadJobLogsForWorkflowRun` + // precisely because that one exists; the run-logs endpoint has no equivalent. + // The logs endpoint answers 302 to a short-lived archive URL. Octokit + // follows redirects and downloads the zip body by default; `redirect: + // "manual"` stops at the 302 so only the `Location` URL is read — the + // zip is never fetched into the Worker (binary handling avoided, per the + // get_artifact metadata-only convention). + const { headers } = await client().request( + "GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs", + { owner, repo, run_id, request: { redirect: "manual" } }, + ); + logRateLimit(headers); + const url = headers.location; + if (url == null || url.length === 0) { + return text(`(no log archive URL returned for run ${run_id})`); + } + const lines = [ + `# Logs for run ${run_id} in ${owner}/${repo}`, + "", + "> Full run log archive (zip of all jobs' logs).", + "", + "- the download URL below is short-lived (expires within minutes) and needs no authentication", + "- to read a specific job's log inline, use `get_job_logs` instead", + `- download (zip): ${url}`, + ]; + return text(truncate(lines.join("\n"))); + }), + ); + server.registerTool( "list_workflow_run_artifacts", { diff --git a/test/actions.test.js b/test/actions.test.js index 7530e3b..8a2fab6 100644 --- a/test/actions.test.js +++ b/test/actions.test.js @@ -3,7 +3,8 @@ import { describe, expect, it } from "vitest"; import { registerActionTools } from "../src/tools/actions.js"; import { captureHandlers, invoke } from "./_helpers/tools.js"; -const stubOctokit = (overrides) => ({ +const stubOctokit = (overrides, { request } = {}) => ({ + request: request ?? (async () => ({ data: "", headers: {} })), rest: { actions: { listWorkflowRuns: async () => ({ data: { workflow_runs: [] }, headers: {} }), @@ -362,6 +363,59 @@ describe("registerActionTools", () => { expect(result.content[0].text).toBe("(no logs for job 9)"); }); + it("get_workflow_run_logs returns the short-lived archive URL without following the redirect", async () => { + const { handlers, server } = captureHandlers(); + let captured; + const octokit = stubOctokit( + {}, + { + request: async (route, params) => { + captured = { route, params }; + return { + status: 302, + data: "", + headers: { location: "https://logs.example/archive.zip?sig=abc" }, + }; + }, + }, + ); + registerActionTools(server, () => octokit); + + const result = await invoke(handlers, "get_workflow_run_logs", { + owner: "o", + repo: "r", + run_id: 555, + }); + const body = result.content[0].text; + expect(captured.route).toContain("/actions/runs/{run_id}/logs"); + expect(captured.params).toMatchObject({ + owner: "o", + repo: "r", + run_id: 555, + request: { redirect: "manual" }, + }); + expect(body).toContain("# Logs for run 555 in o/r"); + expect(body).toContain("https://logs.example/archive.zip?sig=abc"); + expect(body).toContain("get_job_logs"); + expect(result.isError).toBeUndefined(); + }); + + it("get_workflow_run_logs reports when no archive URL is returned", async () => { + const { handlers, server } = captureHandlers(); + const octokit = stubOctokit( + {}, + { request: async () => ({ status: 302, data: "", headers: {} }) }, + ); + registerActionTools(server, () => octokit); + + const result = await invoke(handlers, "get_workflow_run_logs", { + owner: "o", + repo: "r", + run_id: 7, + }); + expect(result.content[0].text).toBe("(no log archive URL returned for run 7)"); + }); + it("list_workflow_run_artifacts renders ID, name, size, expiry, created time", async () => { const { handlers, server } = captureHandlers(); let captured;