feat: admin page to switch external MCP servers to remote MCP servers in place

Merged
QS
14d ago
+17107-4587

GRW-298

Summary

Adds a staff-only External MCP migration page to the admin dashboard. It lists every live server across all organizations that still proxies an external MCP attachment, and switches the selected ones over to remote MCP servers, in place.

Candidates follow the same rule as ToolsetHasExternalMCPProxy: the toolset's latest version references a proxy tool whose attachment sits on the project's active deployment. Each row shows the organization, project, server, /mcp/<slug> address, upstream URL (and the URL it becomes), sign-in provider, tool calls over the last 30 days, and any blockers. The list can be filtered by organization and project; the filters live in the URL.

Migrating a server (server/internal/externalmcp/migration) runs in one transaction per server, using the same lock order as hosted MCP writes:

  • Remote source: creates a remote_mcp_servers row in the same project. Its URL is the one resource the issuer's signed-in sessions recorded, otherwise the attachment's URL with unfilled template query parameters such as ?{TOOLSETS} removed.
  • In-place switch: one UPDATE repoints the existing mcp_servers row from the toolset to the remote source, sets remote_session_issuer_id to the upstream's issuer, and fills user_session_issuer_id from the toolset when it is empty. The row keeps its id, slug, visibility, environment and network access, so its endpoint, plugin entries and mcp:* grants carry on unchanged. Its metadata row moves from the toolset key to the server key (same id).
  • Sessions: stamps the URL on the issuer's sessions that recorded no resource. A remote backend only forwards a credential whose resource matches its URL, so without this every user would have to sign in again.
  • Headers: every system environment value the external MCP proxy sends today becomes a stored, encrypted header, merged and named exactly as externalmcp.BuildHeaders does. Every declared header becomes a pass-through from the MCP-<name> request header clients send today (the display name when one is set). On servers that sign in with OAuth, a declared Authorization is skipped because the sign-in supplies it. A stored value wins over a pass-through of the same name, with a warning.
  • Toolset: disabled (mcp_enabled = false), not deleted, so undo is pointing the row back at the toolset and re-enabling it. Deleting the toolset is a later cleanup step.
  • Audit: the remote source, each header and the server update are recorded under the staff member, plus toolset:migrate_to_remote_mcp.

No user session issuer or remote session client is ever created, and nothing registers with the upstream.

Blockers. A server is listed with the reason and cannot be selected when it:

  • has no user session issuer, or its organization has no metadata record
  • uses an external OAuth server, an OAuth proxy, or has stored OAuth proxy tokens
  • expects users to send their own credentials (header definitions without OAuth)
  • proxies more than one attachment, URL or transport, or has a templated or invalid URL
  • serves other tools or resources, or uses tool variations or prompts
  • is attached to an assistant, a Slack app, an organization collection or a gateway, or is in a plugin through its toolset
  • does not have exactly one server row and one endpoint, or its legacy mcp_slug is not one of its endpoints
  • its server signs in through a different issuer than its toolset
  • its issuer's clients use more than one upstream authorization server, include a self-owned client, or have enterprise-managed authorization bindings
  • its signed-in sessions record more than one resource, or another server on the same issuer is not a remote MCP server for that URL

Admin API. Three methods: listExternalMcpMigrationCandidates, previewExternalMcpMigration and migrateExternalMcpServers. The preview runs every step and rolls it back. A migrate run stops at the first failure and reports each server as migrated, failed with a reason, or not attempted. Writes require a staff session, at most 100 servers per run.

Admin MCP parity: no tool added. This is a short-lived operations migration that ends once the external MCP attachment code is removed. A write tool would need the full proposal and approval flow for a few runs by staff who already use the dashboard.

Motivation

External MCP attachments are being retired (GRW-298). Every server still backed by one needs to keep working as a remote MCP server. A production check found that every candidate already has exactly one server row and one endpoint, so switching that row in place keeps the address, plugins and grants without cloning anything, and reusing the existing sign-in provider and its hand-registered clients keeps people signed in. Anything that would not carry over cleanly is ruled out up front instead of handled by the migration.

Gutternote review steps

  1. Setup: the seeded demo data has no external-MCP-backed servers, so the list starts empty. To see a ready row, seed a project with a completed deployment, an external_mcp_attachments row with a proxy tool definition, an MCP-enabled toolset whose latest version references it, a canonical mcp_servers row (id = toolset_id) with one mcp_endpoints row on the toolset's mcp_slug, and a user session issuer linked to one remote session client. The upstream host must resolve.
  2. Navigate: admin sidebar → Platform Management → External MCP migration (/external-mcp-migration).
  3. Expect: a table with Organization, Project, Server (with /mcp/<slug>), Upstream (with a "Becomes …" line when a template is dropped), Sign-in provider, Calls (30d) and Status. Blocked rows show a red Blocked badge with the reasons and cannot be selected.
  4. Act: pick a project in the Project filter, then Select → All ready, then Migrate N.
  5. Expect: a dialog listing each server's planned actions (create the remote source, headers, switch, disable the toolset) with a Ready badge, or Failed with a reason, in which case the confirm button stays disabled. Confirm, and each server shows Migrated with its server and remote source ids. A toast confirms the run, a Last run section stays above the table, and migrated rows leave the list.

Also check: the empty state ("No servers are backed by an external MCP attachment."), "No servers match these filters.", and the organization filter narrowing the project list.

šŸ¤– Generated with Claude Code


šŸš€ Preview Environment (PR #7612)

Preview URL: https://pr-7612.dev.getgram.ai

ComponentStatusDetailsUpdated (UTC)
āœ… ImagesAvailableContainer images ready2026-10-17 22:14:33.

Gram Preview Bot