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":
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.
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).executeThat 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 growsAfter — 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 lookupsRoot cause fix
- Introduce
Ai::Catalog::McpServersBatchLoaderso 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 singleOrganizations::Organization.with_user(current_user).id_in(...)lookup, returned as an idSet. - Leave
Ai::Catalog::McpServers::ListServiceunchanged — single-version callers continue to use its existingnew(...).executepath.
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, ...)

