Filter blocked external MCP servers from Duo Workflow config

What does this MR do and why?

Enforces the per-server external MCP kill-switch at config build time. When Rails builds the MCP server list for the Duo Workflow executor (/ai/duo_workflows/ws pre-authorization), catalog servers blocked for the request's namespace are excluded, so their tools are never offered to the agent. Because the web frontend opens a new WebSocket per user message and per approval click, a block takes effect on the next tool approval, message, or new session — whichever comes first.

Design notes:

  • The enforcement namespace prefers the project namespace over the group, because project-level blocks are stored against the project namespace (Ai::Catalog::McpServers::SetBlockService). Group-only resolution would silently ignore every project-level block.
  • Group blocks inherit to subgroups and projects via self_and_ancestor_ids; project blocks do not propagate upward.
  • Trusted gitlab/orbit servers are built in a separate code path in McpConfigService#execute and are structurally unaffected by the filter.
  • Gated behind the existing mcp_server_block_enforcement feature flag (root namespace actor, matching its use in workflow_type.rb). Rollout: https://gitlab.com/gitlab-org/gitlab/-/issues/607552
  • Supersedes the per-tool-call gateway design from gitlab-org/modelops/applied-ml/code-suggestions/ai-assist!6445 (closed) (closed): this approach needs one repository instead of four and blocks precisely per server instead of falling back to blocking all external tools. The externalMcpBlocked GraphQL field from that effort is deliberately untouched here; its removal/repurposing is a follow-up tied to the mid-session enforcement decision.
  • Scope: Web Agentic Chat only — currently the only surface that consumes this config and the only one where catalog MCP servers execute. Flows and IDE/CLI follow-ups are tracked separately.
  • No changelog entry: the change is behind a default-disabled feature flag.
  • Existing spec change to be aware of: list_service_spec.rb previously asserted that blocked servers stay listed ("enforcement is per tool call, not at listing") — that expectation encoded the superseded design and is intentionally inverted here.

Database

This MR modifies one existing query in Ai::Catalog::McpServers::ListService#execute (runs once per Duo Workflow WebSocket pre-authorization for agents with catalog MCP servers). No migrations, no new tables, no new indexes.

Query before (still runs when the flag is disabled or no namespace is in scope)

SELECT "ai_catalog_mcp_servers".*
FROM "ai_catalog_mcp_servers"
WHERE "ai_catalog_mcp_servers"."id" IN (999000001, 999000002, 999000003)

Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/55209/commands/158721

Query after (flag enabled, namespace in scope)

SELECT "ai_catalog_mcp_servers".*
FROM "ai_catalog_mcp_servers"
WHERE "ai_catalog_mcp_servers"."id" IN (999000001, 999000002, 999000003)
  AND "ai_catalog_mcp_servers"."id" NOT IN (
    SELECT "ai_catalog_mcp_server_blocks"."ai_catalog_mcp_server_id"
    FROM "ai_catalog_mcp_server_blocks"
    WHERE "ai_catalog_mcp_server_blocks"."namespace_id" IN (9970, 6543)
      AND "ai_catalog_mcp_server_blocks"."ai_catalog_mcp_server_id" IN (999000001, 999000002, 999000003)
  )

Plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/55209/commands/158722

Notes for the database reviewer:

  • The subquery is fully served by the existing composite index idx_ai_catalog_mcp_server_blocks_on_ns_and_server (namespace_id, ai_catalog_mcp_server_id); the outer query uses the primary key as before.
  • namespace_id IN (...) comes from Namespace#self_and_ancestor_ids (traversal_ids, resolved in memory — no extra query), bounded by hierarchy depth (max 20). ai_catalog_mcp_server_id IN (...) is the agent's attached server list (single digits).
  • Both tables are in the main database; no cross-database access. The subquery is inlined, so the request's query count is unchanged.
  • These tables back dark-launch features and are near-empty in production; rows were seeded in the Database Lab clone (IDs visible in the queries) to exercise the index. The namespace IDs stand in for a real ancestry list — only plan shape is affected.

References

Screenshots or screen recordings

No UI changes. Behavior is visible in Agentic Chat: after blocking a server in the MCP Registry, its tools disappear from the agent's toolset on the next message, while other servers' tools and GitLab tools keep working.

How to set up and validate locally

  1. Enable the flags in the rails console:

    Feature.enable(:mcp_client)
    Feature.enable(:mcp_server_block_enforcement)
  2. Create an external MCP server in the AI catalog (for example DeepWiki, https://mcp.deepwiki.com/mcp, no auth) and attach it to an agent.

  3. In Agentic Chat, run the agent and confirm its MCP tools work (for example deepwiki_read_wiki_structure on a public repository).

  4. Block the server: top-level group Settings > GitLab Duo > Change governance > MCP Registry tab > Block.

  5. Send the next chat message: the server's tools are no longer offered (other servers and GitLab tools are unaffected). Blocking from a project's settings enforces for that project only.

  6. Select Allow and send another message: the tools return.

Verification done on this branch: 133 RSpec examples across the four changed files; live end-to-end on GDK through the real /ws endpoint (group/project blocks, subgroup inheritance, sibling isolation, unblock, flag on/off both ways); and a 38-scenario QA pass including permissions, namespace transfer, and deletion edge cases.

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.

Merge request reports

Loading
Loading