Add get_repository_file MCP tool
What does this MR do and why?
Adds a get_repository_file MCP tool that reads one file from a repository at a given
revision, windowed by line via offset/limit so large files cannot saturate an agent's
context. This is the highest-traffic tool in the Agent Platform migration set (~1.7M calls,
~28k users). File content is fetched through the existing GraphQL blob API
(repository.blobs → RepositoryBlob); line windowing, Duo context exclusions, URL parsing
and authorization are a thin custom layer on top of that fetch.
References
- Issue: #605872
- Epic: &22780
- Existing Agent Platform tool:
duo_workflow_service/tools/repository_files.pyin theai-assistproject
GraphQL-backed design and two findings worth a look
Design. Per the issue's implementation plan, the base data path uses the existing
GraphQL blob API (Resolvers::BlobsResolver → Types::Repository::BlobType) rather than
reading repository.blob_at directly. The tool is a Base::GraphqlService + GraphqlTool
pair. The line windowing, the Duo context-exclusion check (EE), URL/ref parsing, and a
read_code authorization all live in the tool as a thin layer around the fetch. The
authorization runs before ref extraction and the exclusion check, so neither reads a
repository the caller cannot access.
Binary handling. The query requests rawTextBlob, not rawBlob. Requesting rawBlob
on a binary file raises a GraphQL UTF-8 serialization error that fails the whole query;
rawTextBlob (BlobPresenter#raw_plain_data) is null for binary content, so it both
avoids the crash and serves as the binary signal. A file that is non-binary but not
valid UTF-8 could still fail to serialize; this is a narrow edge the old blob_at path
did not have.
Not-found errors. GraphQL returns an empty blobs connection for both a missing ref
and a missing file, so the two cannot be told apart from the response. To keep distinct,
actionable messages, an empty result triggers one repository.commit(ref) lookup: a bad
ref returns the ref error, otherwise the file-not-found error (which points the agent at
the search tool). The lookup only runs on the already-failed path.
Truncation. RepositoryBlob has no truncated field, so it is derived from rawSize
vs the bytes returned. That is the same comparison Gitlab::Git::Blob#truncated? makes
internally (size > loaded_size), just computed after the fetch rather than inside the
blob object. The 10 MiB cap matches the previous blob_at limit (both use Gitaly's
MAX_DATA_DISPLAY_SIZE).
Two behaviours inherited from ExtractsRef, both pinned by specs
A URL whose branch name contains slashes is split using the repository's real branch and tag
names, so .../-/blob/my-topic/with-slash/app/x.rb correctly yields the branch
my-topic/with-slash.
When the first path segment is itself a branch, the shorter branch wins:
.../-/blob/feature/with-slash/app/x.rb yields the branch feature and the path
with-slash/app/x.rb, because ExtractsRef::RefExtractor#use_first_path_segment? prefers it. The
web UI behaves identically. Both cases have specs so neither is mistaken for a bug later.
Screenshots or screen recordings
No UI changes.
Agent testing (specifically for whether it will pull for this tool for remote files only)
script at: $6028693
haiku testing
tested with haiku
>> Preflight: endpoint reachability...
HTTP 200
body: {"jsonrpc":"2.0","result":{"tools":[{"name":"get_mcp_server_version","description":"Get the current version of MCP server.","inputSchema":{"type":"object","properties":{},"required":[],"additionalProperties":false},"icons":[{"mimeType":"image/png","src":"https://gdk.test:3443/assets/gitlab_logo-2957
✓ get_repository_file advertised by the endpoint
>> Preflight: does the AGENT actually see the tool via --mcp-config?
mcp__gdk__get_repository_file
✓ agent sees the tool. Running the selection tests.
==================================================================
TEST: 1 local-control (want: Read) [model=haiku, 3 runs]
PROMPT: What does user.rb do?
------------------------------------------------------------------
run 1: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.45Gk8X/user.rb"}
run 2: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.45Gk8X/user.rb"}
run 3: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.45Gk8X/user.rb"}
TALLY get_repository_file=0 local-read=3 no-tool=0 other=0
==================================================================
TEST: 2 remote-explicit (want: mcp__gdk__get_repository_file) [model=haiku, 3 runs]
PROMPT: Read the README.md file from the flightjs/Flight project on GitLab.
------------------------------------------------------------------
run 1: <no tool>
run 2: <no tool>
run 3: Glob {"pattern":"**/Flight**/README.md"} Glob {"pattern":"**/flightjs**/README.md"}
TALLY get_repository_file=0 local-read=1 no-tool=2 other=0
==================================================================
TEST: 3 specific-ref (want: mcp__gdk__get_repository_file) [model=haiku, 3 runs]
PROMPT: Show me files/ruby/popen.rb as it exists on the v1.0.0 tag of the gitlab-org/gitlab-test project on GitLab.
------------------------------------------------------------------
run 1: mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"files/ruby/popen.rb","ref":"v1.0.0"}
run 2: mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"files/ruby/popen.rb","ref":"v1.0.0"}
run 3: <no tool>
TALLY get_repository_file=2 local-read=0 no-tool=1 other=0
==================================================================
TEST: 4 neutral-local-name (watch the bias) [model=haiku, 3 runs]
PROMPT: Get me the contents of user.rb.
------------------------------------------------------------------
run 1: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.45Gk8X/user.rb"}
run 2: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.45Gk8X/user.rb"}
run 3: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.45Gk8X/user.rb"}
TALLY get_repository_file=0 local-read=3 no-tool=0 other=0
==================================================================
TEST: 5 windowing (want: mcp__gdk__get_repository_file+offset) [model=haiku, 3 runs]
PROMPT: Read the first 30 lines of the CHANGELOG file from the gitlab-org/gitlab-test project on GitLab and tell me whether there are more lines.
------------------------------------------------------------------
run 1: mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"CHANGELOG","ref":"HEAD","limit":30,"offset":0}
run 2: mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"CHANGELOG","ref":"HEAD","limit":30}
run 3: <no tool>
TALLY get_repository_file=2 local-read=0 no-tool=1 other=0
==================================================================
TEST: 6 reasoning-probe [model=haiku, 3 runs]
PROMPT: You have this directory checked out locally AND a tool that reads files from GitLab. In one sentence each: when would you use each, and why?
------------------------------------------------------------------
run 1: <no tool>
run 2: <no tool>
run 3: <no tool>
TALLY get_repository_file=0 local-read=0 no-tool=3 other=0
==================================================================sonnet testing
tested with sonnet
>> Preflight: endpoint reachability...
HTTP 200
body: {"jsonrpc":"2.0","result":{"tools":[{"name":"get_mcp_server_version","description":"Get the current version of MCP server.","inputSchema":{"type":"object","properties":{},"required":[],"additionalProperties":false},"icons":[{"mimeType":"image/png","src":"https://gdk.test:3443/assets/gitlab_logo-2957
✓ get_repository_file advertised by the endpoint
>> Preflight: does the AGENT actually see the tool via --mcp-config?
mcp__gdk__get_repository_file
✓ agent sees the tool. Running the selection tests.
==================================================================
TEST: 1 local-control (want: Read) [model=sonnet, 3 runs]
PROMPT: What does user.rb do?
------------------------------------------------------------------
run 1: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.4RR9tW/user.rb"}
run 2: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.4RR9tW/user.rb"}
run 3: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.4RR9tW/user.rb"}
TALLY get_repository_file=0 local-read=3 no-tool=0 other=0
==================================================================
TEST: 2 remote-explicit (want: mcp__gdk__get_repository_file) [model=sonnet, 3 runs]
PROMPT: Read the README.md file from the flightjs/Flight project on GitLab.
------------------------------------------------------------------
run 1: mcp__gdk__get_repository_file {"project_id":"flightjs/flight","file_path":"README.md","ref":"HEAD"}
run 2: mcp__gdk__get_repository_file {"project_id":"flightjs/flight","file_path":"README.md","ref":"HEAD"}
run 3: mcp__gdk__get_repository_file {"project_id":"flightjs/Flight","file_path":"README.md","ref":"HEAD"}
TALLY get_repository_file=3 local-read=0 no-tool=0 other=0
==================================================================
TEST: 3 specific-ref (want: mcp__gdk__get_repository_file) [model=sonnet, 3 runs]
PROMPT: Show me files/ruby/popen.rb as it exists on the v1.0.0 tag of the gitlab-org/gitlab-test project on GitLab.
------------------------------------------------------------------
^[c run 1: mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"files/ruby/popen.rb","ref":"v1.0.0"}
run 2: mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"files/ruby/popen.rb","ref":"v1.0.0"}
run 3: mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"files/ruby/popen.rb","ref":"v1.0.0"}
TALLY get_repository_file=3 local-read=0 no-tool=0 other=0
==================================================================
TEST: 4 neutral-local-name (watch the bias) [model=sonnet, 3 runs]
PROMPT: Get me the contents of user.rb.
------------------------------------------------------------------
run 1: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.4RR9tW/user.rb"}
run 2: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.4RR9tW/user.rb"}
run 3: Glob {"pattern":"**/user.rb"} Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.4RR9tW/user.rb"}
TALLY get_repository_file=0 local-read=3 no-tool=0 other=0
==================================================================
TEST: 5 windowing (want: mcp__gdk__get_repository_file+offset) [model=sonnet, 3 runs]
PROMPT: Read the first 30 lines of the CHANGELOG file from the gitlab-org/gitlab-test project on GitLab and tell me whether there are more lines.
------------------------------------------------------------------
run 1: mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"CHANGELOG","ref":"HEAD","offset":0,"limit":30}
run 2: mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"CHANGELOG","ref":"HEAD","offset":0,"limit":30}
run 3: mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"CHANGELOG","ref":"HEAD","offset":0,"limit":30}
TALLY get_repository_file=3 local-read=0 no-tool=0 other=0
==================================================================
TEST: 6 reasoning-probe [model=sonnet, 3 runs]
PROMPT: You have this directory checked out locally AND a tool that reads files from GitLab. In one sentence each: when would you use each, and why?
------------------------------------------------------------------
run 1: <no tool>
run 2: <no tool>
run 3: <no tool>
TALLY get_repository_file=0 local-read=0 no-tool=3 other=0
==================================================================opus testing
tested with opus (before adding model/multiple runs)
>> Preflight: endpoint reachability...
HTTP 200
body: {"jsonrpc":"2.0","result":{"tools":[{"name":"get_mcp_server_version","description":"Get the current version of MCP server.","inputSchema":{"type":"object","properties":{},"required":[],"additionalProperties":false},"icons":[{"mimeType":"image/png","src":"https://gdk.test:3443/assets/gitlab_logo-2957
✓ get_repository_file advertised by the endpoint
>> Preflight: does the AGENT actually see the tool via --mcp-config?
mcp__gdk__get_repository_file
✓ agent sees the tool. Running the selection tests.
==================================================================
TEST: 1 local-control (want: Read)
PROMPT: What does user.rb do?
------------------------------------------------------------------
TOOLS THE AGENT CHOSE:
-> Glob {"pattern":"**/user.rb"}
-> Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.Sk5Hhy/user.rb"}
FINAL ANSWER:
Both use Ruby 3.0+ endless method definition syntax (`def foo = expr`). There's no validation, no attribute readers, and no other state or behavior.
==================================================================
TEST: 2 remote-explicit (want: mcp__gdk__get_repository_file)
PROMPT: Read the README.md file from the flightjs/Flight project on GitLab.
------------------------------------------------------------------
TOOLS THE AGENT CHOSE:
-> ToolSearch {"query":"select:mcp__gdk__get_repository_file","max_results":1}
-> mcp__gdk__get_repository_file {"project_id":"flightjs/Flight","file_path":"README.md","ref":"HEAD"}
FINAL ANSWER:
Copyright 2013 Twitter, Inc and other contributors. Licensed under the MIT License.
==================================================================
TEST: 3 specific-ref (want: mcp__gdk__get_repository_file)
PROMPT: Show me files/ruby/popen.rb as it exists on the v1.0.0 tag of the gitlab-org/gitlab-test project on GitLab.
------------------------------------------------------------------
TOOLS THE AGENT CHOSE:
-> ToolSearch {"query":"select:mcp__gdk__get_repository_file","max_results":1}
-> mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"files/ruby/popen.rb","ref":"v1.0.0"}
FINAL ANSWER:
==================================================================
TEST: 4 neutral-local-name (watch the bias)
PROMPT: Get me the contents of user.rb.
------------------------------------------------------------------
TOOLS THE AGENT CHOSE:
-> Glob {"pattern":"**/user.rb"}
-> Read {"file_path":"/private/var/folders/kj/zjjncblj1bs9ljvbt7gyst0w0000gn/T/mcp-name-work.Sk5Hhy/user.rb"}
FINAL ANSWER:
A minimal class with an endless-method constructor storing `@name`, and `greet` returning `"Hello, <name>"`.
==================================================================
TEST: 5 windowing (want: mcp__gdk__get_repository_file+offset)
PROMPT: Read the first 30 lines of the CHANGELOG file from the gitlab-org/gitlab-test project on GitLab and tell me whether there are more lines.
------------------------------------------------------------------
TOOLS THE AGENT CHOSE:
-> ToolSearch {"query":"select:mcp__gdk__get_repository_file","max_results":1}
-> mcp__gdk__get_repository_file {"project_id":"gitlab-org/gitlab-test","file_path":"CHANGELOG","ref":"HEAD","offset":0,"limit":30}
FINAL ANSWER:
**Yes, there are more lines.** The file has 667 lines total (~22.8 KB), so 637 remain after these 30. Let me know if you'd like the next chunk or the whole thing.
==================================================================
TEST: 6 reasoning-probe
PROMPT: You have this directory checked out locally AND a tool that reads files from GitLab. In one sentence each: when would you use each, and why?
------------------------------------------------------------------
TOOLS THE AGENT CHOSE:
FINAL ANSWER:
**GitLab file tool:** use it when I need a file as it exists on the remote — a different branch, tag, or commit, a project I haven't cloned, or to confirm what's actually on `main` versus my possibly-stale or dirty local copy.How to set up and validate locally
-
Read a file from a project you have locally:
svc = Mcp::Tools::Manager.new.get_tool(name: 'get_repository_file') svc.set_cred(current_user: User.find_by_username('root')) r = svc.execute(params: { arguments: { 'project_id' => 'flightjs/Flight', 'file_path' => 'README.md', 'ref' => 'master' } }) r[:structuredContent][:metadata] -
Confirm windows abut with no gap or overlap: reading in windows of 50 and concatenating reproduces the file byte for byte.
whole = Project.find_by_full_path('flightjs/Flight').repository.blob_at('master', 'README.md').data acc = +''; offset = 0 loop do p = svc.execute(params: { arguments: { 'project_id' => 'flightjs/Flight', 'file_path' => 'README.md', 'ref' => 'master', 'offset' => offset, 'limit' => 50 } })[:structuredContent] acc << p[:content] break if p[:metadata][:returned_lines].nil? offset = p[:metadata][:returned_lines][:end] break if offset >= p[:metadata][:total_lines] end acc == whole -
Confirm a bad revision and a missing file give different errors:
# "Ref 'nope' not found..." vs "File 'no/such.rb' does not exist at ref 'master'..." -
Confirm exclusions are enforced:
pr = Project.find_by_full_path('flightjs/Flight') pr.project_setting.update!(duo_context_exclusion_settings: { 'exclusion_rules' => ['*.md'] }) # reading README.md now returns an error; reset with duo_context_exclusion_settings: {} -
Inspect and call the tool through the MCP Inspector against your GDK (swap in your own GDK URL):
npx -y @modelcontextprotocol/inspector -- env NODE_TLS_REJECT_UNAUTHORIZED=0 mise x -- npx -y mcp-remote https://gdk.test:3443/api/v4/mcp --debug
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.