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) orproject_id+job_id— exactly one of the two sets, enforced in Ruby rather than a schemaoneOf, since a rootoneOfwould disableadditionalProperties: false. artifact_path(required).byte_offset/byte_limitpaging, using the same names and semantics as theget_joblog facet.byte_limitdefaults to, and caps at, 1 MB, matchingget_repository_file'sMAX_WINDOW_BYTESpattern (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_artifactscheck, 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_pathis 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::FileExclusionServiceagainst the artifact path before the archive is opened, following theget_repository_filepattern, 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 EElist_toolsspecs. get_jobandget_pipelinetool descriptions now point atget_artifact_filefor reading a listed artifact.- Docs: new
get_artifact_filesection indoc/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 |
|---|---|
![]() |
![]() |
| Job-URL identification | Not-found with archive 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/listadvertises the tool (readOnlyHint: true), with a schema payload of 1342 bytes — consistent ordering withget_job(1275) andget_pipeline(1799).- Reading
ci_artifacts.txtreturned the exact content with correct metadata. byte_limit: 5returned"CI bu"plus the truncation instruction.- Reading
rails_sample.jpgreturned the binary error, namingimage/jpeg, 35255 bytes, and the raw viewing URL. - Reading
nope.txtreturned the not-found listing error. - Job-URL identification worked; passing
urltogether withproject_id/job_idwas rejected.
Closes #585022 (closed)



