Add list_merge_requests MCP server tool

What does this MR do and why?

Adds list_merge_requests, a GraphQL-backed MCP server tool that lists or searches merge requests in a project, filtered by author, assignee, reviewer, state, milestone, labels, or free text, with cursor pagination. It is the collection reader that pairs with the single-object get_merge_request. A scope filter resolves "me" to the authenticated user server-side, so an agent can answer "my open merge requests here" without knowing the caller's username. gitlab_merge_request_search is registered as a tool_aliases entry.

Design decisions
  • GraphQL, not REST. The issue's example sketched a REST wrapper (page/per_page), and the MCP pagination guidelines listed list_merge_requests under REST-backed tools. This MR implements it against the GraphQL mergeRequests connection for stable cursor pagination (first/after, returning pageInfo { endCursor hasNextPage }) and native relational filters, executed as current_user so permissions are enforced by GitlabSchema. The pagination guidelines doc is updated to move the tool into the GraphQL-backed list. Needs ratification.
  • scope is emulated. The connection has no scope argument, so created_by_me, assigned_to_me and review_requested map onto the username filters using current_user. Precedence is per field: scope: assigned_to_me with author_username: alice returns merge requests authored by alice and assigned to you, rather than one filter discarding the other.
  • No count in the output. The issue's output schema asks only for a has_more signal, which pageInfo.hasNextPage provides. Types::CountableConnectionType#count with no limit runs an exact COUNT(*) on every call.
  • Exactly-one-of is enforced in Ruby, not JSON Schema. A oneOf would express it declaratively, but SchemaDefaults.with_additional_properties skips applying additionalProperties: false when a composition key is present, and hand-setting it is against convention. Validating in resolve_project keeps the strictness guard and also rejects the "both supplied" case. The rule is stated once in the tool description, where the model reads it while choosing a tool, rather than repeated in each property.
  • labels is a comma-separated string. BaseLabel forbids commas in label titles (format: { with: /\A[^,]+\z/ }), so splitting is lossless, and the string form matches the tool being replaced.
  • Compact output. Each node returns iid, title, state, author.username, source/target branches, and webUrl, plus pageInfo. The id selections are required by the @graphql-eslint/require-selections lint rule, not payload padding.
Considered and deliberately cut

Each of these was built and then removed, to keep the tool to what the issue specifies. An MCP input schema is versioned (_index.md:358), so a parameter is far cheaper to add later than to remove — removal is a breaking change needing a new version plus multi-version support.

  • Group scope (group_id). The issue's schema is project-only, its implementation plan says wrap GET /api/v4/projects/{id}/merge_requests, and it states "Follow-ups: none". The shipped gitlab_merge_request_search is also project-only — its input derives from ProjectResourceInput. (The catalog entry and doc/user/duo_agent_platform/agents/tools.md describe it as "project or group", which is inaccurate.) There is a real argument for group scope — the issue's motivating examples, "my open MRs" and "MRs awaiting my review", are not project-scoped questions — but that is a capability worth proposing on its own merits rather than bundling here. Filed as #606934.
  • include_subgroups. Only meaningful with group scope. Also a bad thing to expose: the GraphQL resolver defaults it to false, so the wrong choice returns an empty list with no error, and issuable_collections.rb:68-69 shows the product answer is always true. Covered in the follow-up.
  • author_id / assignee_id / reviewer_id. Added for schema compatibility with the tool being aliased, then cut. The connection filters people by username only, so these existed purely to be converted back into usernames — and an agent cannot know a numeric user ID without an extra lookup. Nothing in the codebase constructs them for merge request search. Because both tools are currently listed to agents (see below), the Python tool remains the path for any ID-based caller.
  • scope: all. A no-op identical to omitting scope; it cost the model a decision and bought nothing.
Scope of the alias, and what it does and does not protect

gitlab_merge_request_search has never been advertised by the GitLab MCP server — it appears only in the Duo Agent Platform catalogs (ee/lib/ai/catalog/built_in_tool_definitions.rb) and in duo_workflow_service. So unlike the gitlab_searchsearch rename, no MCP client can have the old name cached from our tools/list. The alias is forward-looking: it covers a future migration where Duo agents are repointed at the MCP server while still carrying the Python tool's descriptions. Called with the old name today it resolves and works (verified below).

Related: ai-assist has no mechanism to drop the Python tool when an equivalent MCP tool exists. supersedes only swaps Python tools by client capability, and denied_tools is a runtime governance policy. So until gitlab_merge_request_search is removed from the workflow tool lists in ai-assist, agents will see both. That removal is tracked separately by @terrichu.

Compared with the tool it replaces

Every field on ListMergeRequestInput / ListMergeRequest (name = "gitlab_merge_request_search", backed by MERGE_REQUESTS_API_PATH), mapped onto this tool:

Old field Old definition This tool Notes
url Optional[str] url Project URLs
project_id Optional[Union[int, str]] project_id Numeric ID or full path, as a string — matches all 10 existing MCP tools
author_username Optional[str] author_username
assignee_username Optional[str] assignee_username
reviewer_username Optional[str] reviewer_username
state opened|closed|locked|merged|all state Same five values, now an enum
milestone Optional[str] milestone Kept this name rather than milestone_title
labels Optional[str], comma-separated labels Same form; split server-side
search Optional[str] search Title and description
scope created_by_me|assigned_to_me|all scope Keeps the first two, adds review_requested, drops all (a no-op)
author_id Optional[int] not supported The connection filters by username only. See "Considered and deliberately cut".
assignee_id Optional[int] not supported as above
reviewer_id Optional[int] not supported as above
updated_after In optional_params but never declared on the input model not supported Unreachable in the old tool too
page / per_page Not exposed by the old tool first / after Cursor pagination

Callers of the old tool are the chat, software_development and issue_to_merge_request workflows. All of them keep working: the old tool is still registered, and the alias covers the new name.

Verified against a running GDK

Real POST /api/v4/mcp round trips with a PAT scoped to mcp — not just a spec run.

1. Advertised by tools/list:

params (12): after, assignee_username, author_username, first, labels, milestone,
             project_id, reviewer_username, scope, search, state, url
size: 2008 chars (~502 tokens)
additionalProperties: false     readOnlyHint: true

2. A real call returns merge requests, with pageInfo and no count:

isError=false  keys=["pageInfo", "nodes"]
  !13 [merged] https://gdk.test:3443/toolbox/gitlab-smoke-tests/-/merge_requests/13
  !12 [merged] https://gdk.test:3443/toolbox/gitlab-smoke-tests/-/merge_requests/12

3. All three identification paths work, including the alias:

project_id (numeric)   isError=false  3 node(s)
project_id (full path) isError=false  2 node(s)
url                    isError=false  2 node(s)
gitlab_merge_request_search (alias)   isError=false  2 node(s)

4. Filters work, and validation rejects what it should:

scope=created_by_me    isError=false   state=merged  isError=false  8 node(s)
labels "bug,urgent"    isError=false   milestone     isError=false

no identifier          isError=true   Provide exactly one of: url or project_id
both identifiers       isError=true   Provide exactly one of: url or project_id
group_id               isError=true   group_id is invalid
author_id              isError=true   author_id is invalid
scope: all             isError=true   Invalid scope: 'all'. Must be one of: created_by_me, ...
Known gap: the search rate limiter does not apply

SearchArguments#validate_search_rate_limit! returns early when context[:request] is nil, and Base::GraphqlTool#execution_context sets only current_user and is_sessionless_user. So the search argument here is not throttled the way it is over /api/graphql. Threading the request into the execution context is a base-class change affecting all GraphQL MCP tools, so it is deliberately left out and needs its own issue.

References

  • Issue: #605879 (closed)
  • Epic: &22781 (Merge Request MCP tools)
  • Follow-up for group scope: #606934
  • Follow-up for not-found handling in the base class: #606924
  • Replaces the shipped gitlab_merge_request_search (ListMergeRequest in duo_workflow_service/tools/merge_request.py), kept working through tool_aliases.
  • Follows the tool-proposal process reviewed by the interim mcp-tool-review-board.

Screenshots or screen recordings

No UI changes.

How to set up and validate locally

  1. using mcp inspector manually

    npx -y @modelcontextprotocol/inspector -- env NODE_TLS_REJECT_UNAUTHORIZED=0 mise x -- npx -y mcp-remote https://gdk.test:3443/api/v4/mcp --debug
  2. using a PAT (created through rails console)

     token = User.find_by_username('root').personal_access_tokens.create!(
     name: 'mcp-verify', scopes: [:api, :mcp], expires_at: 7.days.from_now)
     
     token.set_token('someknownvalue'); token.save! # token.revoke! when done
  3. confirm the tool is advertised:

    curl -sk -X POST https://gdk.test:3443/api/v4/mcp \
      -H "Authorization: Bearer <PAT>" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
      | jq '.result.tools[] | select(.name=="list_merge_requests")'
  4. Call it, and confirm pageInfo is present and count is absent:

    curl -sk -X POST https://gdk.test:3443/api/v4/mcp \
      -H "Authorization: Bearer <PAT>" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_merge_requests","arguments":{"project_id":"<project>","first":3}}}' \
      | jq '.result.structuredContent | {keys: keys, pageInfo}'
  5. Confirm the alias resolves, so the old name keeps working:

    curl -sk -X POST https://gdk.test:3443/api/v4/mcp \
      -H "Authorization: Bearer <PAT>" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"gitlab_merge_request_search","arguments":{"project_id":"<project>","state":"opened"}}}' \
      | jq '.result.isError, (.result.structuredContent.nodes | length)'
  6. Confirm ambiguous input is rejected rather than silently resolved one way:

    curl -sk -X POST https://gdk.test:3443/api/v4/mcp \
      -H "Authorization: Bearer <PAT>" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_merge_requests","arguments":{"project_id":"<project>","url":"<project url>"}}}' \
      | jq -r '.result.content[0].text'

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