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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
43 changes: 43 additions & 0 deletions src/tools/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
56 changes: 55 additions & 1 deletion test/actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} }),
Expand Down Expand Up @@ -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;
Expand Down