From dbc59ec7feb1156e2483ad68715b6cd7654739e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 18 Jun 2026 16:44:16 -0300 Subject: [PATCH 1/3] feat: support editing MCP servers in settings --- src/dal/index.ts | 2 + src/dal/mcp-servers.test.ts | 64 +++++++++++++ src/dal/mcp-servers.ts | 42 ++++++++- src/hooks/use-add-server-form.ts | 35 +++++++ src/settings/mcp-servers.test.tsx | 68 +++++++++++++- src/settings/mcp-servers.tsx | 146 ++++++++++++++++++++++++------ 6 files changed, 329 insertions(+), 28 deletions(-) diff --git a/src/dal/index.ts b/src/dal/index.ts index 934813855..cb91e3aed 100644 --- a/src/dal/index.ts +++ b/src/dal/index.ts @@ -72,6 +72,8 @@ export { deleteMcpServer, getAllMcpServers, getRemoteMcpServers, + updateMcpServer, + updateMcpServerWithCredentials, type McpServerWithCredential, } from './mcp-servers' diff --git a/src/dal/mcp-servers.test.ts b/src/dal/mcp-servers.test.ts index e0015d9d5..7f88526b3 100644 --- a/src/dal/mcp-servers.test.ts +++ b/src/dal/mcp-servers.test.ts @@ -13,6 +13,7 @@ import { deleteMcpServer, getAllMcpServers, getRemoteMcpServers, + updateMcpServerWithCredentials, } from './mcp-servers' import { getMcpServerCredentials } from './mcp-secrets' import { resetTestDatabase, setupTestDatabase, teardownTestDatabase } from './test-utils' @@ -316,6 +317,69 @@ describe('MCP Servers DAL', () => { }) }) + describe('updateMcpServerWithCredentials', () => { + it('patches mutable fields and leaves credentials alone when none given', async () => { + const db = getDb() + const id = uuidv7() + await createMcpServerWithCredentials( + db, + { id, name: 'Original', type: 'http', url: 'https://example.com/mcp', enabled: 1 }, + { type: 'bearer', token: 'original-token' }, + ) + + await updateMcpServerWithCredentials(db, id, { name: 'Renamed', url: 'https://new.example.com/mcp' }) + + const servers = await getAllMcpServers(db) + expect(servers[0]?.name).toBe('Renamed') + expect(servers[0]?.url).toBe('https://new.example.com/mcp') + expect(await getMcpServerCredentials(db, id)).toEqual({ type: 'bearer', token: 'original-token' }) + }) + + it('replaces the credential when an object is given', async () => { + const db = getDb() + const id = uuidv7() + await createMcpServerWithCredentials( + db, + { id, name: 'Server', type: 'http', url: 'https://example.com/mcp', enabled: 1 }, + { type: 'bearer', token: 'old-token' }, + ) + + await updateMcpServerWithCredentials(db, id, { name: 'Server' }, { type: 'bearer', token: 'new-token' }) + + expect(await getMcpServerCredentials(db, id)).toEqual({ type: 'bearer', token: 'new-token' }) + }) + + it('deletes the credential when null is given', async () => { + const db = getDb() + const id = uuidv7() + await createMcpServerWithCredentials( + db, + { id, name: 'Server', type: 'http', url: 'https://example.com/mcp', enabled: 1 }, + { type: 'bearer', token: 'doomed' }, + ) + + await updateMcpServerWithCredentials(db, id, { name: 'Server' }, null) + + expect(await getMcpServerCredentials(db, id)).toBeNull() + }) + + it('adds a credential to a previously-uncredentialed server', async () => { + const db = getDb() + const id = uuidv7() + await createMcpServerWithCredentials(db, { + id, + name: 'Open Server', + type: 'http', + url: 'https://example.com/mcp', + enabled: 1, + }) + + await updateMcpServerWithCredentials(db, id, {}, { type: 'bearer', token: 'fresh' }) + + expect(await getMcpServerCredentials(db, id)).toEqual({ type: 'bearer', token: 'fresh' }) + }) + }) + describe('createMcpServer', () => { it('should create a new MCP server', async () => { const serverId = uuidv7() diff --git a/src/dal/mcp-servers.ts b/src/dal/mcp-servers.ts index 4b8175564..e06d63f3d 100644 --- a/src/dal/mcp-servers.ts +++ b/src/dal/mcp-servers.ts @@ -6,7 +6,7 @@ import { and, eq, inArray, isNotNull, isNull } from 'drizzle-orm' import type { AnyDrizzleDatabase } from '../db/database-interface' import { mcpSecretsTable, mcpServersTable } from '../db/tables' import { clearNullableColumns, nowIso } from '../lib/utils' -import { setMcpServerCredentials, type McpServerCredentials } from './mcp-secrets' +import { deleteMcpServerCredentials, setMcpServerCredentials, type McpServerCredentials } from './mcp-secrets' import { type McpServer } from '@/types' import type { DrizzleQueryWithPromise } from '@/types' @@ -81,6 +81,46 @@ export const createMcpServerWithCredentials = async ( }) } +/** + * Patches an MCP server row (and bumps updatedAt). The patch must NOT include + * `id` or `createdAt`; touch only mutable columns. No-op when the id doesn't + * match (the update silently affects zero rows). + */ +export const updateMcpServer = async ( + db: AnyDrizzleDatabase, + id: string, + patch: Partial>, +): Promise => { + await db + .update(mcpServersTable) + .set({ ...patch, updatedAt: nowIso() }) + .where(eq(mcpServersTable.id, id)) +} + +/** + * Updates an MCP server row and (optionally) its on-device credentials in a + * single transaction. Symmetric to {@link createMcpServerWithCredentials}. + * `credentials` semantics: + * - `undefined`: leave existing credential alone (e.g. rename without touching the token) + * - `null`: delete existing credential (user cleared the bearer field) + * - object: replace existing credential + */ +export const updateMcpServerWithCredentials = async ( + db: AnyDrizzleDatabase, + id: string, + patch: Partial>, + credentials?: McpServerCredentials | null, +): Promise => { + await db.transaction(async (tx) => { + if (credentials === null) { + await deleteMcpServerCredentials(tx, id) + } else if (credentials) { + await setMcpServerCredentials(tx, id, credentials) + } + await updateMcpServer(tx, id, patch) + }) +} + /** One server row plus its optional on-device credential, for batch creation. */ export type McpServerWithCredential = { server: Partial & Pick diff --git a/src/hooks/use-add-server-form.ts b/src/hooks/use-add-server-form.ts index 0911eaedc..31f456b1f 100644 --- a/src/hooks/use-add-server-form.ts +++ b/src/hooks/use-add-server-form.ts @@ -9,6 +9,7 @@ import type { probeMcpServerTools } from '@/lib/mcp-connection-test' import { buildMcpHeaders, createMcpTransport, type MCPTransportType } from '@/lib/mcp-transport' import { validateMcpServerUrl } from '@/lib/mcp-url-validation' import type { FetchFn } from '@/lib/proxy-fetch' +import type { McpServer } from '@/types' import { useEffect, useReducer, useRef } from 'react' /** @@ -44,6 +45,8 @@ export const generateServerName = (url: string): string => { type AddServerFormState = { isAddDialogOpen: boolean + /** Non-null when the dialog is editing an existing server (id) instead of adding one. */ + editingServerId: string | null name: string /** True once the user edits the name field, so the URL stops re-deriving it. */ nameManuallyEdited: boolean @@ -56,6 +59,7 @@ type AddServerFormState = { type AddServerFormAction = | { type: 'open-dialog' } + | { type: 'open-edit-dialog'; server: McpServer; bearerToken: string | null } | { type: 'reset' } | { type: 'set-name'; value: string } | { type: 'set-url'; value: string; derivedName: string | null } @@ -68,6 +72,7 @@ type AddServerFormAction = const initialState: AddServerFormState = { isAddDialogOpen: false, + editingServerId: null, name: '', nameManuallyEdited: false, url: '', @@ -81,6 +86,19 @@ const addServerFormReducer = (state: AddServerFormState, action: AddServerFormAc switch (action.type) { case 'open-dialog': return { ...state, isAddDialogOpen: true } + case 'open-edit-dialog': + // Edit prefills every field from the existing row. `nameManuallyEdited` + // is set so a URL change during edit doesn't clobber the existing name. + return { + ...initialState, + isAddDialogOpen: true, + editingServerId: action.server.id, + name: action.server.name ?? '', + nameManuallyEdited: true, + url: action.server.url ?? '', + transport: action.server.type === 'sse' ? 'sse' : 'http', + token: action.bearerToken ?? '', + } case 'reset': return initialState case 'set-name': @@ -119,7 +137,11 @@ export type AddServerFormDeps = { export type UseAddServerFormResult = { isAddDialogOpen: boolean + /** Id of the server being edited, or null when the dialog is in Add mode. */ + editingServerId: string | null openDialog: () => void + /** Opens the dialog in Edit mode with all fields prefilled from the existing server. */ + openEditDialog: (server: McpServer, bearerToken: string | null) => void /** Closes the dialog and clears all add-form state (Cancel / Escape / overlay). */ resetAddDialog: () => void name: string @@ -171,6 +193,17 @@ export const useAddServerForm = ({ const openDialog = () => dispatch({ type: 'open-dialog' }) + // Open the dialog with every field prefilled from an existing server row + + // its on-device bearer token (null for OAuth or no-cred). The auto-detect + // effect will probe the prefilled URL after the standard 700ms debounce, so + // the user must still pass Test Connection before saving — same gate as Add. + const openEditDialog = (server: McpServer, bearerToken: string | null) => { + probeIdRef.current += 1 + lastAutoTestedUrlRef.current = null + onClearDialogError() + dispatch({ type: 'open-edit-dialog', server, bearerToken }) + } + // Closes the Add dialog and clears all add-form state. Bumps the probe id so an // in-flight connection probe can't land its result after the dialog is gone. const resetAddDialog = () => { @@ -302,7 +335,9 @@ export const useAddServerForm = ({ return { isAddDialogOpen: state.isAddDialogOpen, + editingServerId: state.editingServerId, openDialog, + openEditDialog, resetAddDialog, name: state.name, url: state.url, diff --git a/src/settings/mcp-servers.test.tsx b/src/settings/mcp-servers.test.tsx index 5ed3d3dcc..03be9d906 100644 --- a/src/settings/mcp-servers.test.tsx +++ b/src/settings/mcp-servers.test.tsx @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { createMcpServer, getAllMcpServers } from '@/dal' +import { createMcpServer, createMcpServerWithCredentials, getAllMcpServers, getMcpServerCredentials } from '@/dal' import { resetTestDatabase, setupTestDatabase, teardownTestDatabase } from '@/dal/test-utils' import { getDb } from '@/db/database' import { renderWithReactivity, waitForElement } from '@/test-utils/powersync-reactivity-test' @@ -284,6 +284,72 @@ describe('McpServersPage Add & Authorize', () => { }) }) +describe('McpServersPage Edit server', () => { + beforeAll(async () => { + await setupTestDatabase() + }) + + afterAll(async () => { + await teardownTestDatabase() + }) + + beforeEach(async () => { + await resetTestDatabase() + }) + + afterEach(() => { + cleanup() + }) + + it('prefills the dialog from the existing server and persists the patch on Save', async () => { + const db = getDb() + const id = uuidv7() + await createMcpServerWithCredentials( + db, + { id, name: 'Original', type: 'http', url: 'https://old.example.com/mcp', enabled: 1 }, + { type: 'bearer', token: 'original-token' }, + ) + + renderWithReactivity( ['search'] }} />, { + tables: ['mcp_servers', 'mcp_secrets'], + wrapper: McpProviderWrapper, + }) + + const editButton = await waitForElement(() => screen.queryByRole('button', { name: 'Edit server' })) + fireEvent.click(editButton) + + // Dialog title flips to Edit and the existing values are surfaced — name in the + // visible input and token in the password field (URL stays read off the input). + expect(await waitForElement(() => screen.queryByText('Edit MCP Server'))).toBeInTheDocument() + const nameInput = screen.getByPlaceholderText('Server name (used to prefix tools)') as HTMLInputElement + const urlInput = screen.getByPlaceholderText('http://localhost:8000/mcp/') as HTMLInputElement + const tokenInput = screen.getByPlaceholderText('Bearer token or API key') as HTMLInputElement + expect(nameInput.value).toBe('Original') + expect(urlInput.value).toBe('https://old.example.com/mcp') + expect(tokenInput.value).toBe('original-token') + // Bulk-import toggle is hidden in Edit mode. + expect(screen.queryByRole('radio', { name: 'Advanced (JSON)' })).not.toBeInTheDocument() + + // Rename and let the auto-probe fire so the success gate unlocks Save. + fireEvent.change(nameInput, { target: { value: 'Renamed' } }) + await flushAutoProbe() + expect(screen.getByText('Connection successful!')).toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })) + await getClock().runAllAsync() + }) + + const rows = await getAllMcpServers(db) + expect(rows).toHaveLength(1) + expect(rows[0]?.id).toBe(id) + expect(rows[0]?.name).toBe('Renamed') + expect(rows[0]?.url).toBe('https://old.example.com/mcp') + // The prefilled bearer token survives an edit that doesn't touch it. + expect(await getMcpServerCredentials(db, id)).toEqual({ type: 'bearer', token: 'original-token' }) + }) +}) + describe('McpServersPage probe lifecycle', () => { beforeAll(async () => { await setupTestDatabase() diff --git a/src/settings/mcp-servers.tsx b/src/settings/mcp-servers.tsx index d56dd1dd8..d7cbab5a0 100644 --- a/src/settings/mcp-servers.tsx +++ b/src/settings/mcp-servers.tsx @@ -27,6 +27,7 @@ import { createMcpServerWithCredentials, deleteMcpServer, getRemoteMcpServers, + updateMcpServerWithCredentials, } from '@/dal' import type { McpServerCredentials } from '@/dal/mcp-secrets' import { useDatabase } from '@/contexts' @@ -36,7 +37,7 @@ import { type McpServer } from '@/types' import { useMutation } from '@tanstack/react-query' import { useQuery } from '@powersync/tanstack-react-query' import { eq } from 'drizzle-orm' -import { Check, Copy, Globe, LockKeyhole, Plus, RefreshCw, Server, Trash2, X } from 'lucide-react' +import { Check, Copy, Globe, LockKeyhole, Pencil, Plus, RefreshCw, Server, Trash2, X } from 'lucide-react' import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' import { useLocation, useNavigate } from 'react-router' import { v7 as uuidv7 } from 'uuid' @@ -281,6 +282,19 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep return acc }, {}) + // Prefill source for Edit: the on-device bearer token (if the stored credential + // is bearer). OAuth credentials are intentionally not surfaced — the token UI + // only accepts bearer values, and OAuth is managed via the Authorize buttons. + const bearerTokenById = mcpSecrets.reduce>((acc, row) => { + if (row.credentials) { + const cred = JSON.parse(row.credentials) as McpServerCredentials + if (cred.type === 'bearer') { + acc[row.id] = cred.token + } + } + return acc + }, {}) + // Tools for connected servers. The query keys on each CONNECTION's identity // (`id:generation`, where the generation changes whenever the provider swaps in // a fresh client instance) — keying on the id set alone would serve the dead @@ -332,6 +346,36 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep }, }) + const updateServerMutation = useMutation({ + mutationFn: async ({ + id, + name, + url, + transport, + token, + originalCredentialType, + }: { + id: string + name: string + url: string + transport: MCPTransportType + token: string + originalCredentialType: StoredCredentialType + }) => { + // Credential semantics on edit: + // - token filled → store as bearer (replaces any prior credential, OAuth included) + // - token blank + originally bearer → delete the credential (user cleared the field) + // - token blank + originally oauth/none → leave the credential alone, so a rename + // of an OAuth-authorized server doesn't wipe its tokens + const credentials = token + ? ({ type: 'bearer', token } as const) + : originalCredentialType === 'bearer' + ? null + : undefined + await updateMcpServerWithCredentials(db, id, { name, url, type: transport }, credentials) + }, + }) + const importServersMutation = useMutation({ mutationFn: async (parsed: ParsedMcpServer[]): Promise => { await createMcpServersWithCredentials( @@ -374,6 +418,26 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep resetLocalDialogState() } + const handleUpdateServer = async () => { + if (!form.editingServerId || !newServerUrl || !isUrlValid) { + return + } + await updateServerMutation.mutateAsync({ + id: form.editingServerId, + name: resolveServerName(), + url: newServerUrl, + transport: newServerTransport, + token: newServerToken, + originalCredentialType: credentialTypeById[form.editingServerId] ?? 'none', + }) + form.resetAddDialog() + resetLocalDialogState() + } + + const handleEditClick = (server: McpServer) => { + form.openEditDialog(server, bearerTokenById[server.id] ?? null) + } + /** * Advanced mode: parse the pasted JSON config and create every server it * describes (all-or-nothing). On a parse error nothing is created and the @@ -431,8 +495,8 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep if (testResult.kind === 'idle' && newServerUrl && isUrlValid) { testConnection() } else if (testResult.kind === 'success') { - handleAddServer() - } else if (testResult.kind === 'needs-oauth') { + form.editingServerId ? handleUpdateServer() : handleAddServer() + } else if (testResult.kind === 'needs-oauth' && !form.editingServerId) { handleAddAndAuthorize() } } @@ -586,30 +650,35 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep - Add MCP Server - Add a new MCP server + {form.editingServerId ? 'Edit MCP Server' : 'Add MCP Server'} + + {form.editingServerId ? 'Edit MCP server' : 'Add a new MCP server'} + - { - if (value !== 'simple' && value !== 'advanced') { - return - } - // Each mode owns a different error source (JSON import vs OAuth - // authorization). Clear both on switch so a stale message from the - // mode you're leaving can't surface under the new mode's UI. - setImportError(null) - clearDialogError() - setMode(value) - }} - className="w-full flex-shrink-0" - > - Simple - Advanced (JSON) - + {/* Advanced (JSON) is bulk-import only — irrelevant when editing a single server. */} + {!form.editingServerId && ( + { + if (value !== 'simple' && value !== 'advanced') { + return + } + // Each mode owns a different error source (JSON import vs OAuth + // authorization). Clear both on switch so a stale message from the + // mode you're leaving can't surface under the new mode's UI. + setImportError(null) + clearDialogError() + setMode(value) + }} + className="w-full flex-shrink-0" + > + Simple + Advanced (JSON) + + )}
{mode === 'simple' ? ( @@ -747,7 +816,16 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep > Cancel - {mode === 'advanced' ? ( + {form.editingServerId ? ( + + ) : mode === 'advanced' ? ( @@ -908,6 +986,22 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep

{isEnabled ? 'Disable server' : 'Enable server'}

+ + + + + +

Edit server

+
+
setDeleteConfirmOpen(open ? server.id : null)} From 23579e42ddb65c5e3bcbc9faabfa4593b8c5a6d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 18 Jun 2026 19:02:00 -0300 Subject: [PATCH 2/3] fix: let users save metadata-only mcp server edits --- src/hooks/use-add-server-form.test.ts | 44 +++++++++++++++++++++++++++ src/hooks/use-add-server-form.ts | 34 ++++++++++++++++++--- src/settings/mcp-servers.tsx | 8 ++++- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/hooks/use-add-server-form.test.ts b/src/hooks/use-add-server-form.test.ts index 3c0256e2a..ec9eb49ca 100644 --- a/src/hooks/use-add-server-form.test.ts +++ b/src/hooks/use-add-server-form.test.ts @@ -206,6 +206,50 @@ describe('useAddServerForm', () => { expect(result.current.testResult.kind).toBe('needs-oauth') }) + it('keeps a passing test result when only the name is edited', async () => { + const { result } = renderForm(makeDeps()) + + act(() => result.current.openDialog()) + act(() => result.current.changeUrl('https://tools.example.com/mcp')) + await act(async () => { + getClock().tick(700) + await getClock().runAllAsync() + }) + expect(result.current.testResult.kind).toBe('success') + + // Renaming doesn't change what the probe verifies, so the success must survive — + // otherwise Save Changes gets stuck disabled with no obvious recovery. + act(() => result.current.changeName('My Server')) + expect(result.current.testResult.kind).toBe('success') + }) + + it('reports hasConnectionEdits only for connection-affecting fields in edit mode', () => { + const { result } = renderForm(makeDeps()) + + act(() => + result.current.openEditDialog( + { id: 's1', name: 'GitHub', url: 'https://api.github.com/mcp', type: 'http', enabled: 1 } as never, + 'tok-1', + ), + ) + expect(result.current.hasConnectionEdits).toBe(false) + + // Name is metadata, not part of the probe — must not flip the flag. + act(() => result.current.changeName('Renamed')) + expect(result.current.hasConnectionEdits).toBe(false) + + act(() => result.current.changeUrl('https://api.github.com/mcp/v2')) + expect(result.current.hasConnectionEdits).toBe(true) + }) + + it('hasConnectionEdits is true in Add mode (no original snapshot)', () => { + const { result } = renderForm(makeDeps()) + + act(() => result.current.openDialog()) + // No original to diff against — Add must keep the existing test-success Save gate. + expect(result.current.hasConnectionEdits).toBe(true) + }) + it('resets all form state on resetAddDialog', async () => { const { result } = renderForm(makeDeps()) diff --git a/src/hooks/use-add-server-form.ts b/src/hooks/use-add-server-form.ts index 31f456b1f..579898d51 100644 --- a/src/hooks/use-add-server-form.ts +++ b/src/hooks/use-add-server-form.ts @@ -55,6 +55,11 @@ type AddServerFormState = { token: string isTestingConnection: boolean testResult: TestConnectionResult | { kind: 'idle' } + /** Snapshot of url/transport/token at edit-open. Used to detect whether the + * user changed a connection-affecting field, so the Save gate can skip the + * fresh-probe requirement on a metadata-only edit (e.g. rename) where the + * existing credential — including OAuth — is presumed valid. Null in Add mode. */ + originalConnection: { url: string; transport: MCPTransportType; token: string } | null } type AddServerFormAction = @@ -80,25 +85,31 @@ const initialState: AddServerFormState = { token: '', isTestingConnection: false, testResult: { kind: 'idle' }, + originalConnection: null, } const addServerFormReducer = (state: AddServerFormState, action: AddServerFormAction): AddServerFormState => { switch (action.type) { case 'open-dialog': return { ...state, isAddDialogOpen: true } - case 'open-edit-dialog': + case 'open-edit-dialog': { // Edit prefills every field from the existing row. `nameManuallyEdited` // is set so a URL change during edit doesn't clobber the existing name. + const url = action.server.url ?? '' + const transport: MCPTransportType = action.server.type === 'sse' ? 'sse' : 'http' + const token = action.bearerToken ?? '' return { ...initialState, isAddDialogOpen: true, editingServerId: action.server.id, name: action.server.name ?? '', nameManuallyEdited: true, - url: action.server.url ?? '', - transport: action.server.type === 'sse' ? 'sse' : 'http', - token: action.bearerToken ?? '', + url, + transport, + token, + originalConnection: { url, transport, token }, } + } case 'reset': return initialState case 'set-name': @@ -156,6 +167,12 @@ export type UseAddServerFormResult = { testResult: TestConnectionResult | { kind: 'idle' } isTestingConnection: boolean serverCapabilities: string[] + /** True when a connection-affecting field (url/transport/token) differs from + * the value loaded at edit-open. Always true in Add mode (no original + * snapshot). Callers gate the Save-Changes test-success requirement on this + * so a metadata-only edit can save without re-probing — important for OAuth + * servers, whose empty-token probe would classify as `needs-oauth`. */ + hasConnectionEdits: boolean testConnection: () => Promise /** Leaving the URL field probes immediately (unless the debounce already did). */ handleUrlBlur: () => void @@ -314,7 +331,9 @@ export const useAddServerForm = ({ } const changeName = (value: string) => { - resetConnectionTest() + // Name doesn't participate in the probe (only url/transport/token do), so a + // rename must not invalidate a passing test — otherwise Save Changes gets + // stuck disabled until the user re-edits a connection field or retests. dispatch({ type: 'set-name', value }) } @@ -351,6 +370,11 @@ export const useAddServerForm = ({ isTestingConnection: state.isTestingConnection, // Derived: the discovered tools live on a successful result — no separate state to keep in sync. serverCapabilities: state.testResult.kind === 'success' ? state.testResult.tools : [], + hasConnectionEdits: + !state.originalConnection || + state.url !== state.originalConnection.url || + state.transport !== state.originalConnection.transport || + state.token !== state.originalConnection.token, testConnection, handleUrlBlur, resolveServerName, diff --git a/src/settings/mcp-servers.tsx b/src/settings/mcp-servers.tsx index d7cbab5a0..e37e6a819 100644 --- a/src/settings/mcp-servers.tsx +++ b/src/settings/mcp-servers.tsx @@ -820,7 +820,13 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep