Enable MCP server for existing self-managed instances

What does this MR do and why?

In GitLab 19.5, when the MCP server becomes generally available, self-managed and Dedicated instances that have MCP switched off get it switched on. If an admin ever changed it, it stays as they left it.

New installs already get MCP on by default. Most existing instances are off only because of how we filled in the setting in 19.0 and 19.2. We copied it from Duo availability and instance-level AI beta features, and AI beta features is off by default. Tying them together was deliberate at the time, but today MCP and Duo are separate settings. So for most instances, "off" was never an admin's choice.

What the migration changes

The post-deployment migration sets application_settings.mcp_server_settings.mcp_server_enabled to true where it is false, unless the instance audit log shows an admin changed MCP.

  • Duo availability and AI beta features play no part.
  • It only ever turns the setting from off to on.
  • If the key isn't stored, the setting already reads as on through the model default, so it's left alone.

How "an admin changed it" is detected

An admin change to this setting writes two application_setting_updated events: one for the mcp_server_enabled key, and one for the whole mcp_server_settings value. Migrations don't write any event at all. So if we see one of these events, we know a human made the change, not a migration.

The migration skips the instance if there's any such event since 2026-03-26, no matter which way the change went. The reasoning: if an admin already made a choice about this setting, we shouldn't touch it.

The trade-off: if an admin turned MCP on, and the 19.2 resync migration later turned it back off, it'll stay off. This should be rare. In 19.0 and 19.1, the admin toggle was hidden behind a feature flag that defaults off, and the API had no parameter for it either. Anyone who does hit this can turn it back on with one click.

I checked the event format by running a real change through ApplicationSettings::UpdateService in a spec and copying the stored details into the spec fixtures.

The work item said instances had no dedicated event for this, but they do. I'll correct that there.

Known limits

  • There's no way to tell "never decided" from "saw it was off and was happy with that".
  • Audit events are a paid-tier feature. Free instances never have them, so every Free instance that's off will be turned on.
  • Audit events can expire, so a missing event can also mean an expired one.
  • If the audit scan takes more than 10 minutes, the migration leaves MCP off, as though an admin had changed it. The admin can turn it on by hand.

Query

  • The migration does nothing on GitLab.com (Gitlab.com?). There, MCP access is checked per group, so the instance value has no effect.
  • Elsewhere, it first reads the single application_settings row, and stops if MCP is already on.
  • Only when MCP is off does it look in instance_audit_events for any admin MCP change. That's a separate query, because the two tables are in different schemas.
  • There's no index on event_name or details, so the scan can be slow. It runs in its own transaction with a 10-minute statement timeout. If it hits that limit, the migration logs a message and leaves MCP off.
  • The migration runs outside the usual migration transaction (disable_ddl_transaction!), so a timed-out scan doesn't abort the whole migration. The UPDATE runs in with_lock_retries.
  • The scan is limited to events from 2026-03-26 onward. Its cost depends on how many instance audit events an instance has. Most self-managed instances have far fewer than GitLab.com.

References

How to set up and validate locally

  1. In the Rails console, turn MCP off directly, which leaves no audit event:

    ApplicationSetting.current.update_column(:mcp_server_settings, { 'mcp_server_enabled' => false })
  2. Run bundle exec rails db:migrate and confirm ApplicationSetting.current_without_cache.mcp_server_enabled is true.

  3. Roll back the migration with bundle exec rails db:migrate:down VERSION=20260922130000 (this is a no-op). Then, in Admin > Settings > General > Visibility and access controls, turn the MCP server off and save, which records the audit event.

  4. Run bundle exec rails db:migrate again and confirm the setting stays false.

Database review

Queries

None of these run on GitLab.com. Plans are from a GitLab.com clone, which is the largest dataset available.

1. Is MCP off?

SELECT 1 FROM application_settings WHERE (mcp_server_settings->>'mcp_server_enabled')::boolean IS FALSE LIMIT 1

It reads the same single row as query 3. See the Seq Scan node in that plan: 5 ms.

2. Has an admin ever changed MCP? (runs only if query 1 returns a row, with a 10-minute statement timeout)

SELECT 1
FROM instance_audit_events
WHERE created_at >= '2026-03-26'
  AND event_name = 'application_setting_updated'
  AND details ~ E'\n:change: mcp_server_(enabled|settings)\n'
LIMIT 1

Query plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/58876/commands/163928

On GitLab.com this took 40.8 s on a cold cache. It read every instance audit event since March and found none. That's over GitLab.com's 15 s migration statement timeout, and it's one reason the migration skips GitLab.com. Elsewhere the scan gets a 10-minute timeout, about 15 times this cold-cache run. Nearly all the time is disk reads. Self-managed and Dedicated instances only run it when their value is off, and their tables are much smaller.

Plan
 Limit  (cost=1000.00..40096.83 rows=1 width=4) (actual time=40773.066..40787.587 rows=0 loops=1)
   Buffers: shared hit=2121328 read=254076 dirtied=1243
   I/O Timings: read=106011.082 write=0.000
   ->  Gather  (cost=1000.00..509258.79 rows=13 width=4) (actual time=40773.064..40787.583 rows=0 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Parallel Append  (cost=0.00..508257.49 rows=13 width=4) (actual time=40768.433..40768.438 rows=0 loops=3)
               ... (partitions 202610 to 202703 are empty: 0.005 to 0.107 ms each) ...
               ->  Parallel Index Scan using instance_audit_events_202607_author_id_created_at_id_idx on gitlab_partitions_dynamic.instance_audit_events_202607 instance_audit_events_5  (cost=0.42..72846.11 rows=1 width=4) (actual time=21840.631..21840.631 rows=0 loops=1)
                     Index Cond: (instance_audit_events_5.created_at >= '2026-03-26 00:00:00+00'::timestamp with time zone)
                     Filter: ((instance_audit_events_5.details ~ '\n:change: mcp_server_(enabled|settings)\n'::text) AND (instance_audit_events_5.event_name = 'application_setting_updated'::text))
                     I/O Timings: read=19256.524 write=0.000
               ... (same shape for partitions 202603 to 202609) ...
Settings: effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off', random_page_cost = '1.5', seq_page_cost = '4'

3. Turn MCP on (runs only if query 2 returns no row)

UPDATE application_settings
SET mcp_server_settings = jsonb_set(mcp_server_settings, '{mcp_server_enabled}', 'true')
WHERE (mcp_server_settings->>'mcp_server_enabled')::boolean IS FALSE

Query plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/58371/commands/163082

It takes 21 ms on a cold cache and updates one row. application_settings has a single row.

Plan
 Update on public.application_settings  (cost=0.00..12.02 rows=0 width=0) (actual time=20.767..20.767 rows=0 loops=1)
   Buffers: shared hit=160 read=41 dirtied=5
   WAL: records=6 fpi=4 bytes=19367
   I/O Timings: read=19.376 write=0.000
   ->  Seq Scan on public.application_settings  (cost=0.00..12.02 rows=1 width=38) (actual time=5.475..5.477 rows=1 loops=1)
         Filter: (((application_settings.mcp_server_settings ->> 'mcp_server_enabled'::text))::boolean IS FALSE)
         Buffers: shared hit=22 read=15 dirtied=1
         WAL: records=1 fpi=1 bytes=4041
         I/O Timings: read=5.292 write=0.000
Settings: work_mem = '230MB', jit = 'off', random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB'

Rollback and what to do if something goes wrong

  • down is a no-op: the earlier value wasn't an admin's choice, so there's nothing to restore.
  • An admin can turn MCP off again in Admin > Settings > General > Visibility and access controls.

What users would see if it's wrong

  • An instance turned on that shouldn't be: users can connect AI tools to GitLab through MCP. They only get access to what they can already see, but the admin didn't intend to allow it.
  • An instance skipped that should be on: the MCP API keeps rejecting requests until an admin turns the setting on. That's how things work today.

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 Jessie Young

Merge request reports

Loading
Loading