Batch-load MCP servers for AI catalog agent versions

Why:

Each agent version stores its own MCP server list, so resolving mcpServers for N versions in one GraphQL request hit the database N times — an N+1 that grew with version count.

What:

Types::Ai::Catalog::AgentVersionType#mcp_servers now calls the batch loader.

How:

A new Ai::Catalog::McpServersBatchLoader collects every version id in the same GraphQL execution and answers them in 3 queries total, regardless of N.

Documentation of how MCP Servers are used in the AI catalog: Please refer here

Domain context

An AI catalog item is the agent identity — the thing a user discovers and configures in GitLab's AI Catalog. For example, "Atlassian Planner Agent" or "Linear Project Management Agent":

Screenshot_2026-04-20_at_05.26.56

Each catalog item can have many agent versions. Each version is a saved snapshot of the agent's configuration:

Each version is a saved snapshot of the agent's configuration. In the agent configuration screen below, the visible parts of that snapshot are Tools, MCP servers, and the System prompt:

Field What it is Example value
system_prompt Standing instructions that shape how the agent behaves "Help me get the latest issues and prioritize the most important ones first"
tools Built-in GitLab tool IDs the agent can call [search_issues, create_issue]
mcp_servers MCP server IDs the agent version references [Atlassian MCP Server, Linear]

You can see those same fields in the agent configuration screen: Tools, MCP servers, and the System prompt textarea.

Screenshot_2026-04-20_at_05.26.42

Two versions of the same agent therefore differ in any of those fields — most often the prompt and the MCP server list evolve together.

AI catalog item: "Atlassian Planner Agent"

├─ Agent version 1.0.0
│     ├─ system_prompt: "Help me get the latest issues..."
│     ├─ user_prompt:    "{{user_question}}"
│     ├─ tools:          []
│     └─ MCP servers:    [Atlassian MCP Server]

└─ Agent version 2.0.0
      ├─ system_prompt: "Help me get the latest issues, prioritize them,
      │                  and cross-reference them with Confluence pages."
      ├─ user_prompt:    "{{user_question}}"
      ├─ tools:          [search_issues, summarize_thread]
      └─ MCP servers:    [Atlassian MCP Server, Confluence]

Because each version carries its own MCP server list, a single GraphQL request resolving many versions needs a separate set per version.

Root cause analysis

The GraphQL field Types::Ai::Catalog::AgentVersionType#mcp_servers resolved each version independently via:

Ai::Catalog::McpServers::ListService.new(object, current_user).execute

That service path is correct for one version, but it has no shared point where item version IDs from the same GraphQL request can be collected before loading MCP servers.

As a result, each mcpServers field could issue its own MCP server lookup, so repeated work grew with the number of versions resolved in the same GraphQL request.

How it works

Before — each version loads MCP servers separately

GraphQL request resolves N AiCatalogAgentVersion nodes

├─ version 1 -> ListService.new(v1, user).execute
│                └─ MCP server lookup
├─ version 2 -> ListService.new(v2, user).execute
│                └─ MCP server lookup

└─ version N -> ListService.new(vN, user).execute
                 └─ MCP server lookup

Result: repeated MCP server lookups as N grows

After — versions queue their MCP server loads together

GraphQL request resolves N AiCatalogAgentVersion nodes

├─ version 1 -> McpServersBatchLoader.load_for(v1, user)
├─ version 2 -> McpServersBatchLoader.load_for(v2, user)

└─ version N -> McpServersBatchLoader.load_for(vN, user)


BatchLoader collects all requested version ids


One batch loads the needed item versions, allowed org ids, and MCP servers


Each version receives only its own MCP servers

Result: same GraphQL response, fewer repeated database lookups

Root cause fix

  • Introduce Ai::Catalog::McpServersBatchLoader so the N+1 is solved at the GraphQL field, the one place that sees the whole batch.
  • Replace the per-org Ability.allowed? loop with a single Organizations::Organization.with_user(current_user).id_in(...) lookup, returned as an id Set.
  • Leave Ai::Catalog::McpServers::ListService unchanged — single-version callers continue to use its existing new(...).execute path.

Database Queries part of the MR

Query 1 — item versions lookup (scope :id_in)

Please note: No ORDER BY,LIMIT or OFFSET is applicable to this query.

SELECT "ai_catalog_item_versions"."id",
       "ai_catalog_item_versions"."release_date",
       "ai_catalog_item_versions"."created_at",
       "ai_catalog_item_versions"."updated_at",
       "ai_catalog_item_versions"."organization_id",
       "ai_catalog_item_versions"."ai_catalog_item_id",
       "ai_catalog_item_versions"."schema_version",
       "ai_catalog_item_versions"."version",
       "ai_catalog_item_versions"."definition",
       "ai_catalog_item_versions"."created_by_id"
FROM "ai_catalog_item_versions"
WHERE "ai_catalog_item_versions"."id" IN ($1, $2, ...)

Query 2 — organization preload (scope :with_organization)

Please note: No ORDER BY,LIMIT or OFFSET is applicable to this query.

SELECT "organizations".*
FROM "organizations"
WHERE "organizations"."id" IN ($1, $2, ...)
-- No ORDER BY. No LIMIT. No OFFSET.

Query 3 — MCP servers lookup

SELECT "ai_catalog_mcp_servers".*
FROM   "ai_catalog_mcp_servers"
WHERE  "ai_catalog_mcp_servers"."id" IN ($1, $2, ...)
Edited by Mohnish G Jadwani

Merge request reports

Loading