diff --git a/litellm/models/mcp_server.py b/litellm/models/mcp_server.py
index af2efa822b0..c2f20ac55da 100644
--- a/litellm/models/mcp_server.py
+++ b/litellm/models/mcp_server.py
@@ -101,6 +101,12 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
byok_description: List[str] = Field(default_factory=list)
byok_api_key_help_url: Optional[str] = None
has_user_credential: Optional[bool] = None
+ has_configured_client: Optional[bool] = Field(
+ default=None,
+ description=(
+ "Response-only indicator that the stored (redacted) credentials include an OAuth client_id; never persisted"
+ ),
+ )
source_url: Optional[str] = None
timeout: Optional[float] = None
max_concurrent_requests: Optional[int] = None
diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
index 1d681b43b9e..eb56bc3858e 100644
--- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
+++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@@ -4988,6 +4988,7 @@ class MCPServerManager:
is_byok=server.is_byok,
byok_description=server.byok_description,
byok_api_key_help_url=server.byok_api_key_help_url,
+ has_configured_client=bool(server.client_id),
source_url=server.source_url,
instructions=server.instructions,
timeout=server.timeout,
diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py
index cab2a51a8ca..4c09f8038df 100644
--- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py
@@ -473,13 +473,31 @@ if MCP_AVAILABLE:
def _redact_mcp_credentials(
mcp_server: LiteLLM_MCPServerTable,
) -> LiteLLM_MCPServerTable:
- """Return a copy of the MCP server object with credentials removed."""
+ """Return a copy of the MCP server object with credentials removed.
+
+ Stamps ``has_configured_client`` before redacting so the admin edit form
+ can tell that a stored OAuth app exists without ever seeing its value
+ (the URL-change "app may not match upstream" warning needs exactly this
+ bit; the stored ``client_id`` itself is encrypted and never returned).
+ Derives from the credentials blob when the object carries one (DB reads),
+ otherwise preserves a truthy flag already stamped upstream
+ (``_build_mcp_server_table`` on the registry list path, whose tables
+ never include the blob).
+ """
try:
redacted_server = mcp_server.model_copy(deep=True)
except AttributeError:
redacted_server = mcp_server.copy(deep=True) # type: ignore[attr-defined]
+ stored_credentials = getattr(mcp_server, "credentials", None)
+ stored_client_id = stored_credentials.get("client_id") if isinstance(stored_credentials, dict) else None
+ setattr(
+ redacted_server,
+ "has_configured_client",
+ bool(stored_client_id or getattr(mcp_server, "has_configured_client", None)),
+ )
+
if hasattr(redacted_server, "credentials"):
setattr(redacted_server, "credentials", None)
@@ -548,6 +566,8 @@ if MCP_AVAILABLE:
# admin configured. Non-admins get the per-user vars they must fill in
# from the dedicated /user-env-vars/status endpoint instead.
sanitized.env_vars = None
+ # Only the admin edit form needs the stored-app indicator.
+ sanitized.has_configured_client = None
return sanitized
def _sanitize_mcp_server_list_for_non_admin(
@@ -591,6 +611,7 @@ if MCP_AVAILABLE:
sanitized.health_check_error = None
sanitized.last_health_check = None
+ sanitized.has_configured_client = None
sanitized.created_by = None
sanitized.updated_by = None
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py
index 7c55bd4560f..96a5a273149 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py
@@ -7335,3 +7335,44 @@ def test_build_mcp_server_table_carries_null_oauth2_flow():
table = manager._build_mcp_server_table(server)
assert table.oauth2_flow is None
+
+
+def test_build_mcp_server_table_stamps_has_configured_client():
+ """The list endpoint serves registry servers through this conversion WITHOUT the
+ credentials blob, so the redaction layer cannot see the stored client there. The
+ build must stamp has_configured_client from the registry's decrypted client_id or
+ the edit form never learns a saved OAuth app exists (its URL-change "app may not
+ match upstream" warning would stay silent for stored apps)."""
+ manager = MCPServerManager()
+ server = MCPServer(
+ server_id="stored-app-server",
+ name="stored_app_server",
+ server_name="stored_app_server",
+ alias="stored_app_server",
+ url="https://up.example.com/mcp",
+ transport=MCPTransport.http,
+ auth_type=MCPAuth.true_passthrough,
+ client_id="org-slack-app-client-id",
+ client_secret="org-slack-app-secret",
+ )
+
+ table = manager._build_mcp_server_table(server)
+
+ assert table.has_configured_client is True
+
+
+def test_build_mcp_server_table_has_configured_client_false_without_client():
+ manager = MCPServerManager()
+ server = MCPServer(
+ server_id="no-app-server",
+ name="no_app_server",
+ server_name="no_app_server",
+ alias="no_app_server",
+ url="https://up.example.com/mcp",
+ transport=MCPTransport.http,
+ auth_type=MCPAuth.true_passthrough,
+ )
+
+ table = manager._build_mcp_server_table(server)
+
+ assert table.has_configured_client is False
diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py
index a669a277d2b..062a1c5e049 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py
@@ -5376,3 +5376,118 @@ async def test_edit_mcp_server_snapshot_failure_skips_purge_but_edit_succeeds():
assert result.server_id == server_id
mock_purge.assert_not_awaited()
+
+
+def test_redact_stamps_has_configured_client_from_stored_blob():
+ """The GET redacts credentials to null, so the edit form cannot see a stored OAuth
+ app; has_configured_client is the non-secret existence bit the URL-change "app may
+ not match upstream" warning keys on. Redaction must stamp it from the blob it is
+ about to remove, and must not leak or mutate the blob itself."""
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ _redact_mcp_credentials,
+ )
+
+ server = generate_mock_mcp_server_db_record()
+ server.credentials = {"client_id": "encrypted-client", "client_secret": "encrypted-secret"}
+
+ redacted = _redact_mcp_credentials(server)
+
+ assert redacted.has_configured_client is True
+ assert redacted.credentials is None
+ assert server.credentials == {"client_id": "encrypted-client", "client_secret": "encrypted-secret"}
+
+
+@pytest.mark.parametrize(
+ "credentials",
+ [None, {"auth_value": "top-secret"}, {"client_id": ""}],
+ ids=["no-blob", "no-client-in-blob", "empty-client-id"],
+)
+def test_redact_stamps_has_configured_client_false_without_stored_client(credentials):
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ _redact_mcp_credentials,
+ )
+
+ server = generate_mock_mcp_server_db_record()
+ server.credentials = credentials
+
+ redacted = _redact_mcp_credentials(server)
+
+ assert redacted.has_configured_client is False
+ assert redacted.credentials is None
+
+
+def test_redact_preserves_build_time_has_configured_client():
+ """The list endpoint serves registry servers whose table objects never carry the
+ credentials blob; _build_mcp_server_table stamps the flag instead. Redaction must
+ preserve that stamp rather than resetting it to False for lack of a blob."""
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ _redact_mcp_credentials,
+ )
+
+ server = generate_mock_mcp_server_db_record()
+ server.credentials = None
+ server.has_configured_client = True
+
+ redacted = _redact_mcp_credentials(server)
+
+ assert redacted.has_configured_client is True
+
+
+def test_sanitized_views_drop_has_configured_client():
+ """Only the admin edit form needs the stored-app indicator; the non-admin and
+ virtual-key discovery views must not reveal whether an OAuth app is configured."""
+ import litellm.proxy.management_endpoints.mcp_management_endpoints as mgmt
+
+ server = generate_mock_mcp_server_db_record()
+ server.credentials = {"client_id": "encrypted-client"}
+
+ assert mgmt._sanitize_mcp_server_for_non_admin(server).has_configured_client is None
+ assert mgmt._sanitize_mcp_server_for_virtual_key(server).has_configured_client is None
+
+
+@pytest.mark.asyncio
+async def test_fetch_single_mcp_server_returns_has_configured_client():
+ """End to end through GET /v1/mcp/server/{id}: a stored client surfaces only as
+ has_configured_client=True while the credentials stay redacted."""
+ mock_server = generate_mock_mcp_server_db_record(server_id="server-1", alias="Server 1")
+ mock_server.credentials = {"client_id": "encrypted-client", "client_secret": "encrypted-secret"}
+
+ mock_prisma_client = MagicMock()
+
+ mock_health_result = generate_mock_mcp_server_db_record(server_id="server-1", alias="Server 1")
+ mock_health_result.status = "healthy"
+ mock_health_result.last_health_check = datetime.now()
+ mock_health_result.health_check_error = None
+
+ mock_user_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
+
+ with (
+ patch(
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
+ return_value=mock_prisma_client,
+ ),
+ patch(
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
+ AsyncMock(return_value=mock_server),
+ ),
+ patch(
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager.health_check_server",
+ AsyncMock(return_value=mock_health_result),
+ ),
+ patch(
+ "litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view",
+ return_value=True,
+ ),
+ ):
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ fetch_mcp_server,
+ )
+
+ result = await fetch_mcp_server(
+ request=_make_mock_request(),
+ server_id="server-1",
+ user_api_key_dict=mock_user_auth,
+ )
+
+ assert result.has_configured_client is True
+ assert result.credentials is None
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.test.tsx
index 0a09ef3f856..b5503695ad3 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.test.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.test.tsx
@@ -51,4 +51,21 @@ describe("PassthroughAuthorizeSection credential-class-aware copy", () => {
);
expect(screen.getByText(/registered for the previous upstream/)).toBeInTheDocument();
});
+
+ it("hides the keep+warn banner while the remove-stored-app checkbox is checked", () => {
+ render(
+
- You changed the upstream URL or endpoints; the OAuth app entered here was registered for the previous upstream - and may not be valid. Update the client ID, or clear it to use dynamic client registration. + You changed the upstream URL or endpoints; the OAuth app configured for this server was registered for the + previous upstream and may not be valid. Enter a client ID registered for the new upstream, or remove the app + to use dynamic client registration.
)}