Add get_artifact_file MCP tool to read artifact archive files

Summary

Adds a new get_artifact_file MCP tool (CustomService, read-only) that reads one file from inside a CI/CD job's artifacts archive as text, named to parallel get_repository_file. This is the second of two stacked MRs implementing #585022 (closed) (MCP Tool request - CI/CD Artifacts), stacked on the 585022-artifact-facets branch. A tool proposal was posted on the issue and reviewed by @terrichu (backend) and @amandarueda (product); their feedback is incorporated below.

Parameters

  • Job identification: either url (job URL) or project_id + job_id — exactly one of the two sets, enforced in Ruby rather than a schema oneOf, since a root oneOf would disable additionalProperties: false.
  • artifact_path (required).
  • byte_offset / byte_limit paging, using the same names and semantics as the get_job log facet. byte_limit defaults to, and caps at, 1 MB, matching get_repository_file's MAX_WINDOW_BYTES pattern (review-board feedback from @terrichu).

Why not a REST passthrough

The REST route GET /projects/:id/jobs/:job_id/artifacts/*artifact_path delegates zip extraction to Workhorse via send_artifacts_entry. An in-process MCP JSON-RPC response can't delegate to Workhorse, so the tool reads the zip entry in Ruby instead, following the precedent in lib/supply_chain/artifacts_reader.rb (use_open_file + Zip::File), which is also used by pages deployments.

Security model

From the review-board thread on the issue:

  • The tool reads only the archive artifact (Ci::Build#artifacts_file, i.e. job_artifacts_archive). Report artifact types (dotenv, secret_detection, junit, etc.) are separate records and are structurally unreachable through this path, so no file-type blocklist is needed — confirmed with @terrichu on the issue.
  • Authorization is a project-level find plus a build-level :read_job_artifacts check, which folds in the project's artifact-access settings. A job the caller can't read returns the same "Job not found or inaccessible" error as a missing one, so there's no way to probe for existence.
  • artifact_path is validated: non-empty, UTF-8, no NUL bytes, no trailing slash, no path-traversal sequences.
  • AI context file-exclusion rules apply to the file read: an EE override checks Ai::FileExclusionService against the artifact path before the archive is opened, following the get_repository_file pattern, and returns the same "excluded from AI context" error. Direction settled with @ck3g and @jessieay in #628639; the listing facets return names and sizes only, so they don't run the check. Updating the exclusion-settings UI wording ("project files and directories") stays a follow-up of that discussion.

Binary files

Binary content is returned as an error instead of bytes, carrying the file name, job ID, detected MIME type (libgit2-style detection over the first 8000 bytes plus ruby-magic), the size, and the browser URL to view the file. This follows @amandarueda's feedback that the agent must be able to tell the user where to find content it can't render.

Not-found handling

When the requested path doesn't exist, the error lists up to 20 file paths that do exist in the archive, read from the artifacts metadata file (which also answers missing-path queries without downloading the archive). This goes beyond the strict proposal text — it's an agent-self-correction addition — flagged here for reviewer awareness.

Archive guards

Reading one entry means downloading the whole archive server-side in a synchronous request, so two guards refuse oversized work with a pointer to the download URL: archives larger than MAX_ARCHIVE_BYTES (20 MB), and archives whose metadata lists more than MAX_ARCHIVE_ENTRIES (1,000) entries — Zip::File.open builds one Ruby object per entry, so entry count is a memory cost independent of byte size. Making the byte cap admin-configurable is under discussion in #629157, together with a ranged-read design that could remove the whole-archive download entirely.

Reads are bounded end to end: an offset at or past the file size returns an empty window without inflating anything, byte_offset carries a 100 MB schema maximum as the per-call inflate ceiling, the window is clamped to the file size, and the size itself is taken from the GitLab-written metadata artifact in preference to the archive's self-declared entry size.

Other changes

  • Registered in Mcp::Tools::Manager::CUSTOM_TOOLS; annotation entries added to both CE and EE list_tools specs.
  • get_job and get_pipeline tool descriptions now point at get_artifact_file for reading a listed artifact.
  • Docs: new get_artifact_file section in doc/user/model_context_protocol/mcp_server_tools.md, noting that report artifacts (junit, dotenv) are not part of the archive and can't be read with this tool.

Specs

Coverage: full schema lock, happy path against the ci_build_artifacts.zip fixture, nested path, byte windowing (truncation + system instruction, windows joining up to the whole file, offset past EOF without inflating, window clamped when the archive lies about the entry size), direct unit tests of the end-of-stream probe, binary error content, not-found listing, directory-entry refusal, archive without metadata still readable, object storage (:remote_store), no-archive / expired / expired-but-locked availability, oversized-archive and too-many-entries refusals, url/id identification matrix (including relative URL root, percent-encoded and non-http URLs, literal + in paths), path validation, an authorization matrix (missing job, unreadable project, readable job with non-public artifacts, anonymous), and EE context-exclusion rules (match, no-match, negation, archive not opened).

Note for reviewers: ee/spec/lib/ai/tool_rules/governable_tools_namespace_spec.rb currently fails on the merge base too (get_user), unrelated to this MR.

How I verified

Full manual pass in MCP Inspector (MCP Jam → mcp-remote → nginx at gdk.test:3443) on a seeded job carrying the ci_build_artifacts.zip fixture:

Read a file Byte windowing + truncation instruction
mcpjam_get_artifact_file_read mcpjam_byte_window_truncation
Job-URL identification Not-found with archive listing
mcpjam_url_identification mcpjam_not_found_listing

With a *.txt exclusion rule set on the project, reading ci_artifacts.txt returns "File 'ci_artifacts.txt' is excluded from AI context by this project's settings and cannot be read."; with the rule cleared, the same call returns the content. Passing url together with project_id/job_id is rejected with the mutual-exclusion error. Reading rails_sample.jpg returns the binary refusal naming image/jpeg and the raw viewing URL.

Also ran a JSON-RPC round trip against GDK:

  • tools/list advertises the tool (readOnlyHint: true), with a schema payload of 1342 bytes — consistent ordering with get_job (1275) and get_pipeline (1799).
  • Reading ci_artifacts.txt returned the exact content with correct metadata.
  • byte_limit: 5 returned "CI bu" plus the truncation instruction.
  • Reading rails_sample.jpg returned the binary error, naming image/jpeg, 35255 bytes, and the raw viewing URL.
  • Reading nope.txt returned the not-found listing error.
  • Job-URL identification worked; passing url together with project_id/job_id was rejected.

Closes #585022 (closed)

Edited by Tian Gao

Merge request reports

Loading
Loading