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.blobsRepositoryBlob); 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.py in the ai-assist project
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::BlobsResolverTypes::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

  1. 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]
  2. 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
  3. 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'..."
  4. 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: {}
  5. 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.

Edited by Terri Chu

Merge request reports

Loading