Add full_patch diff detail to get_merge_request facet

What does this MR do and why?

Adds a detail: full_patch option to the get_merge_request MCP tool's diffs facet. The facet previously returned change stats only (summary totals and per-file additions and deletions); raw per-file patch text was reachable only through the standalone get_merge_request_diffs tool. full_patch consumes the new MergeRequest.diffs GraphQL field (added in the stacked MR below) and returns per-file patch text alongside the per-file stats, so an agent can read an MR's diffs inline without a second tool call. Patch text is paginated: diffs_first sets the page size (default 20, max 100) and diffs_after takes the cursor from pageInfo.endCursor.

This is additive: the standalone get_merge_request_diffs tool is left fully in place. The facet and the standalone tool coexist.

An EE override of the tool reuses Ai::FileExclusionService so files a project excludes from Duo context are stripped from the patch text, matching what the REST endpoint's filter_diffs_for_mcp does.

Part B of the diffs work tracked by #605878 (closed).

⚠️ Stacked on the MR that adds MergeRequest.diffs (branch 605878-diffs-full-patch-field, !248993 (merged)). Review/merge that first — this MR's .graphql query does not compile without it.

What changed
  • New detail parameter (get_merge_request_service.rb), enum %w[none stats full_patch], applies only when include is ["diffs"]:
    • none — summary counts only.
    • stats — summary plus per-file additions and deletions. This is the default, so the facet's existing behavior is unchanged when detail is omitted.
    • full_patch — adds raw per-file patch text.
  • GraphQL variables (get_merge_request_tool.rb): detail maps to includeDiffFiles (true unless detail: none) and includeDiffPatches (true only for full_patch), which gate the diffStats and the new diffs { nodes { … diff } pageInfo { … } } selections. include keeps the base's capped-array shape (Array(params[:include])).
  • Exclusion parity lives in ee/app/services/ee/mcp/tools/merge_requests/get_merge_request_tool.rb, which post-filters the returned diffs through Ai::FileExclusionService — the same service the REST diffs endpoint uses via filter_diffs_for_mcp. Files excluded from a project's Duo context are omitted from full_patch.
  • Pagination. diffs(first:, after:) with nodes and pageInfo, wired to the new diffs_first / diffs_after parameters. pageInfo.hasNextPage means a large merge request reports that more files exist instead of silently returning a partial diff. This is the same class of defect as #599320, where an agent flow silently missed everything past the first 20 files; that issue is about the Python list_merge_request_diffs tool in ai-assist, so this does not close it, but the facet does not repeat the mistake.
  • No tool removed. get_merge_request_diffs keeps its registration, route_setting :mcp, GITLAB_PREAPPROVED_TOOLS entry, list_tools/call_tool coverage, and doc section.
  • Complexity override raised 260 → 400 in ee/spec/graphql/all_queries_spec.rb. The static analyzer sums every @include facet even though only one is ever active per call, and a connection additionally multiplies its subtree by the page size, taking the computed score to 384. The override and its "false positive" rationale were introduced by the base MR; this widens it.
  • Multiversion compatibility warning is a false positive here. Danger flags MRs touching both a .graphql document and backend schema code, because a frontend can deploy ahead of the backend. app/graphql/queries/mcp/ is not frontend code — it is a server-side query document executed by the MCP tool inside the same Rails process, so the query and the schema always deploy together.

MCP tool proposal: this extends an existing tool's input schema additively (a new optional detail parameter and a new enum value) rather than adding a net-new tool, so no separate MCP Tool Proposal issue is required.

References

Screenshots or screen recordings

No UI changes.

Before After

How to set up and validate locally

Agentic validation

Using a script to test this out: $6036632

Testing using sonnet
>> Minting a local-GDK PAT (api+mcp) — boots Rails, ~30-60s...
>> Preflight OK. Project: top-level-public/top-project-public   target branch: main
>> Seeding branch mcp-getmr-1787169146 with 4 files...
>> Seeded MR !10 (diff populated)

==================================================================
A. include=[diffs], detail omitted -> default is stats (per-file add/del, NO patch text)
   PASS A returns success
   PASS A diffStatsSummary reports changed files
   PASS A has per-file diffStats
   PASS A has NO diffs patch block (correct for stats)

B. detail=none -> summary counts only (no per-file stats, no patches)
   PASS B returns success
   PASS B has diffStatsSummary
   PASS B has NO per-file diffStats
   PASS B has NO diffs patch block

C. detail=full_patch -> raw per-file patch text + stats + pageInfo
   PASS C returns success
   PASS C still includes per-file diffStats
   PASS C diffs.nodes present
   PASS C at least one node has non-empty patch text (4)
   PASS C has pageInfo.endCursor

D. pagination: full_patch + diffs_first=2 -> capped page, hasNextPage, working cursor
   PASS D page 1 returns success
   PASS D page 1 capped to 2 (<=2)
   PASS D page 1 hasNextPage=true
   PASS D page 1 endCursor present
   PASS D page 2 returns success
   PASS D page 2 returns different files (no overlap)

E. detail is a no-op without diffs in include (include=[commits], detail=full_patch)
   PASS E returns success
   PASS E no diffs block
   PASS E no diff summary (diffs not requested)

F. url form resolves the same MR with full_patch
   PASS F url-form returns success
   PASS F resolved MR !10 via url

==================================================================
G. agentic smoke (needs ANTHROPIC_API_KEY)

------------------------------------------------------------------
AGENTIC: read patches inline   [model=sonnet, 3 runs]
PROMPT: Show me the actual code changes (the raw patch/diff text) in merge request !10 of project top-level-public/top-project-public. I want to read the diff hunks inline, not just a summary.
  run 1: mcp__gdk__get_merge_request_diffs {"id":"top-level-public/top-project-public","merge_request_iid":10}
  run 2: mcp__gdk__get_merge_request_diffs {"id":"top-level-public/top-project-public","merge_request_iid":10}
  run 3: mcp__gdk__get_merge_request {"project_id":"top-level-public/top-project-public","merge_request_iid":10,"include":["diffs"],"detail":"full_patch"}
  TALLY  get_merge_request=1  get_merge_request_diffs=2  no-tool=0  |  runs-that-got-patch-text=3/3

------------------------------------------------------------------
AGENTIC: review the diff   [model=sonnet, 3 runs]
PROMPT: I'm reviewing merge request !10 in top-level-public/top-project-public. Pull its per-file changes so you can quote the changed lines back to me.
  run 1: mcp__gdk__get_merge_request {"project_id":"top-level-public/top-project-public","merge_request_iid":10,"include":["diffs"],"detail":"full_patch"}
  run 2: mcp__gdk__get_merge_request {"project_id":"top-level-public/top-project-public","merge_request_iid":10,"include":["diffs"],"detail":"full_patch"}
  run 3: mcp__gdk__get_merge_request_diffs {"id":"top-level-public/top-project-public","merge_request_iid":10}
  TALLY  get_merge_request=2  get_merge_request_diffs=1  no-tool=0  |  runs-that-got-patch-text=3/3

==================================================================
H. EE file exclusion: an excluded file drops out of full_patch (opt-in: TEST_EXCLUSION=1)
   PASS H returns success
   PASS H excluded file mcp_diff_1.txt dropped from full_patch
   PASS H non-excluded file mcp_diff_2.txt still present
   restored prior exclusion setting

==================================================================
>> Cleanup...
   closed MR !10
   deleted branch mcp-getmr-1787169146
   revoked test PAT

==================================================================
RESULT: 27 passed, 0 failed
Testing using opus
>> Minting a local-GDK PAT (api+mcp) — boots Rails, ~30-60s...
>> Preflight OK. Project: top-level-public/top-project-public   target branch: main
>> Seeding branch mcp-getmr-1787169319 with 4 files...
>> Seeded MR !11 (diff populated)

==================================================================
A. include=[diffs], detail omitted -> default is stats (per-file add/del, NO patch text)
   PASS A returns success
   PASS A diffStatsSummary reports changed files
   PASS A has per-file diffStats
   PASS A has NO diffs patch block (correct for stats)

B. detail=none -> summary counts only (no per-file stats, no patches)
   PASS B returns success
   PASS B has diffStatsSummary
   PASS B has NO per-file diffStats
   PASS B has NO diffs patch block

C. detail=full_patch -> raw per-file patch text + stats + pageInfo
   PASS C returns success
   PASS C still includes per-file diffStats
   PASS C diffs.nodes present
   PASS C at least one node has non-empty patch text (4)
   PASS C has pageInfo.endCursor

D. pagination: full_patch + diffs_first=2 -> capped page, hasNextPage, working cursor
   PASS D page 1 returns success
   PASS D page 1 capped to 2 (<=2)
   PASS D page 1 hasNextPage=true
   PASS D page 1 endCursor present
   PASS D page 2 returns success
   PASS D page 2 returns different files (no overlap)

E. detail is a no-op without diffs in include (include=[commits], detail=full_patch)
   PASS E returns success
   PASS E no diffs block
   PASS E no diff summary (diffs not requested)

F. url form resolves the same MR with full_patch
   PASS F url-form returns success
   PASS F resolved MR !11 via url

==================================================================
G. agentic smoke (needs ANTHROPIC_API_KEY)

------------------------------------------------------------------
AGENTIC: read patches inline   [model=opus, 3 runs]
PROMPT: Show me the actual code changes (the raw patch/diff text) in merge request !11 of project top-level-public/top-project-public. I want to read the diff hunks inline, not just a summary.
  run 1: mcp__gdk__get_merge_request {"project_id":"top-level-public/top-project-public","merge_request_iid":11,"include":["diffs"],"detail":"full_patch"}
  run 2: mcp__gdk__get_merge_request {"project_id":"top-level-public/top-project-public","merge_request_iid":11,"include":["diffs"],"detail":"full_patch"}
  run 3: mcp__gdk__get_merge_request {"project_id":"top-level-public/top-project-public","merge_request_iid":11,"include":["diffs"],"detail":"full_patch"}
  TALLY  get_merge_request=3  get_merge_request_diffs=0  no-tool=0  |  runs-that-got-patch-text=3/3

------------------------------------------------------------------
AGENTIC: review the diff   [model=opus, 3 runs]
PROMPT: I'm reviewing merge request !11 in top-level-public/top-project-public. Pull its per-file changes so you can quote the changed lines back to me.
  run 1: mcp__gdk__get_merge_request {"project_id":"top-level-public/top-project-public","merge_request_iid":11,"include":["diffs"],"detail":"full_patch"}
  run 2: mcp__gdk__get_merge_request {"project_id":"top-level-public/top-project-public","merge_request_iid":11,"include":["diffs"],"detail":"full_patch"}
  run 3: mcp__gdk__get_merge_request {"project_id":"top-level-public/top-project-public","merge_request_iid":11,"include":["diffs"],"detail":"full_patch"}
  TALLY  get_merge_request=3  get_merge_request_diffs=0  no-tool=0  |  runs-that-got-patch-text=3/3

==================================================================
H. EE file exclusion: an excluded file drops out of full_patch (opt-in: TEST_EXCLUSION=1)
   PASS H returns success
   PASS H excluded file mcp_diff_1.txt dropped from full_patch
   PASS H non-excluded file mcp_diff_2.txt still present
   restored prior exclusion setting

==================================================================
>> Cleanup...
   closed MR !11
   deleted branch mcp-getmr-1787169319
   revoked test PAT

==================================================================
RESULT: 27 passed, 0 failed

Manual testing

  1. Restart Rails so the tool registry re-memoizes (gdk restart rails-web).
  2. List tools and confirm get_merge_request_diffs is still present and get_merge_request advertises the detail parameter with full_patch:
    curl -s -X POST "$GDK_URL/api/v4/mcp" -H "Authorization: Bearer $PAT" \
      -H 'Content-Type: application/json' \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
  3. Call the facet and confirm per-file patch text comes back (include is an array; integer params need as: :json):
    curl -s -X POST "$GDK_URL/api/v4/mcp" -H "Authorization: Bearer $PAT" \
      -H 'Content-Type: application/json' \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_merge_request","arguments":{"project_id":"gitlab-org/gitlab","merge_request_iid":1,"include":["diffs"],"detail":"full_patch"}}}'
  4. Page a large merge request: pass diffs_first: 5, then send the returned pageInfo.endCursor back as diffs_after and confirm you get different files and that pageInfo.hasNextPage is accurate.
  5. Set a project Duo context exclusion rule and confirm the excluded file's patch is absent.

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
Loading