Add list_releases MCP server tool

What does this MR do and why?

This MR adds a new MCP server tool called list_releases. It lists a project's releases. The most recently released release comes first. The tool lives in app/services/mcp/tools/releases/list_releases_service.rb as Mcp::Tools::Releases::ListReleasesService. This class extends Base::CustomService. It is registered in app/services/mcp/tools/manager.rb under CUSTOM_TOOLS. The tool is marked readOnlyHint: true.

Callers identify the project with url or project_id. Exactly one of them is required. Paging uses page and per_page. The state parameter filters by release state. It accepts released, upcoming and all. The default is released. Each entry returns tag_name, name, released_at, upcoming and assets. assets holds the release asset links, each with a name and a url. Release notes and source archives are not returned. Notes belong to a future get_ tool. Source archives can be derived from the tag. The response also has a metadata object with page, per_page and has_more.

The tool reads through ReleasesFinder instead of wrapping the existing GET /projects/:id/releases REST endpoint. That endpoint's entity returns the full release payload, including description_html, commit, author, evidences, milestones, source archives, and _links, which is far more than an agent needs and would waste context. Going through the finder directly lets the tool return metadata only and also accept the url identifier, which the REST route does not support.

Authorization requires :read_release on the project. A project the caller cannot read and a project that does not exist both return the identical error message, so the tool can't be used to probe for the existence of private projects.

Docs are updated in doc/user/model_context_protocol/mcp_server_tools.md.

How to test locally

  1. Check out the branch and restart Rails. The tool registry is memoized, so a running server will not advertise a newly added tool.

    gdk restart rails-web
  2. Create a project with a couple of releases and a personal access token with the mcp scope. This prints the token and the project path.

    bundle exec rails runner '
    u = User.find_by_username("root")
    p = Project.find_by_full_path("#{u.namespace.full_path}/mcp-releases-demo") ||
        Projects::CreateService.new(u, name: "mcp-releases-demo", namespace_id: u.namespace.id, initialize_with_readme: true).execute
    if p.releases.none?
      3.times { |i| r = p.releases.create!(tag: "v#{i + 1}.0", name: "Release #{i + 1}.0", description: "notes #{i + 1}", author: u, released_at: (3 - i).days.ago)
        r.links.create!(name: "Binary", url: "https://example.com/v#{i + 1}.0/binary") if i == 2 }
    end
    t = u.personal_access_tokens.create!(name: "mcp-test-#{SecureRandom.hex(4)}", scopes: [:mcp], expires_at: 2.days.from_now, organization: u.organizations.first)
    raw = "glpat-" + SecureRandom.hex(20); t.set_token(raw); t.save!
    puts "TOKEN=#{raw}"; puts "PROJECT=#{p.full_path}"
    '
  3. Confirm the tool is advertised. Look for list_releases in the response.

    curl -s -X POST http://gdk.test:3000/api/v4/mcp -H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[] | select(.name=="list_releases")'
  4. Call the tool. Expect three releases newest first, v3.0 carrying a Binary asset link, and metadata.has_more false.

    curl -s -X POST http://gdk.test:3000/api/v4/mcp -H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_releases","arguments":{"project_id":"<PROJECT>"}}}' | jq -r '.result.structuredContent'
  5. Check pagination. Expect two releases and has_more true.

    curl -s -X POST http://gdk.test:3000/api/v4/mcp -H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_releases","arguments":{"project_id":"<PROJECT>","per_page":2}}}' | jq -r '.result.structuredContent.metadata'
  6. Check the state filter. A project whose releases are all in the past returns nothing for upcoming.

    curl -s -X POST http://gdk.test:3000/api/v4/mcp -H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_releases","arguments":{"project_id":"<PROJECT>","state":"upcoming"}}}' | jq -c '.result.structuredContent.releases'
  7. Optionally, check the error paths: omitting both identifiers, or supplying both, returns "Provide exactly one of: url or project_id"; a per_page above 100 is rejected by the schema; an unknown argument is rejected.

Substitute <TOKEN> and <PROJECT> in the above with the values printed by step 2.

If you'd rather skip the manual steps, run the specs:

bundle exec rspec spec/services/mcp/tools/releases/list_releases_service_spec.rb spec/requests/api/mcp/handlers/call_tool_spec.rb spec/requests/api/mcp/handlers/list_tools_spec.rb ee/spec/requests/api/mcp/handlers/list_tools_spec.rb

Queries

This MR adds two scopes to app/models/release.rb, Release.released and Release.upcoming. There is no migration and no new index. Both queries below use production values, from the plans @terrichu ran in session 55350.

Full query for state: released, which is the default, page 1, per_page 20:

SELECT "releases".*
FROM "releases"
WHERE "releases"."project_id" = 278964
  AND NOT (("releases"."tag" = '' OR "releases"."tag" IS NULL))
  AND "releases"."released_at" <= '2026-08-25 19:04:52.368731'
ORDER BY "releases"."released_at" DESC
LIMIT 20

Execution plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/55350/commands/158990

Full query for state: upcoming:

SELECT "releases".*
FROM "releases"
WHERE "releases"."project_id" = 278964
  AND NOT (("releases"."tag" = '' OR "releases"."tag" IS NULL))
  AND "releases"."released_at" > '2026-08-25 19:04:52.368731'
ORDER BY "releases"."released_at" DESC
LIMIT 20

Execution plan: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/55350/commands/158991

state: all runs the same query without the released_at predicate, which is what the tool did before this change.

Both queries filter on project_id and released_at, and sort by released_at. The existing index index_releases_on_project_id_and_released_at_and_id already covers (project_id, released_at, id).

Review question: does adding the released_at predicate change which index the planner chooses for the existing sort? The two scopes select very different amounts of data. released matches almost every row, and upcoming matches almost none.

References

Edited by Alex Filatov

Merge request reports

Loading
Loading