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.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 0911eaedc..579898d51 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 @@ -52,10 +55,16 @@ 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 = | { 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 +77,7 @@ type AddServerFormAction = const initialState: AddServerFormState = { isAddDialogOpen: false, + editingServerId: null, name: '', nameManuallyEdited: false, url: '', @@ -75,12 +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': { + // 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, + transport, + token, + originalConnection: { url, transport, token }, + } + } case 'reset': return initialState case 'set-name': @@ -119,7 +148,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 @@ -134,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 @@ -171,6 +210,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 = () => { @@ -281,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 }) } @@ -302,7 +354,9 @@ export const useAddServerForm = ({ return { isAddDialogOpen: state.isAddDialogOpen, + editingServerId: state.editingServerId, openDialog, + openEditDialog, resetAddDialog, name: state.name, url: state.url, @@ -316,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/hooks/use-mcp-sync.tsx b/src/hooks/use-mcp-sync.tsx index 456422810..dc0f10f3c 100644 --- a/src/hooks/use-mcp-sync.tsx +++ b/src/hooks/use-mcp-sync.tsx @@ -11,7 +11,7 @@ import { useEffect } from 'react' export const useMcpSync = () => { const db = useDatabase() - const { servers, addServer, removeServer, updateServerStatus } = useMCP() + const { servers, addServer, removeServer, updateServer } = useMCP() const { data: dbServers = [] } = useQuery({ queryKey: ['mcp-servers'], @@ -45,17 +45,35 @@ export const useMcpSync = () => { } } - // Update server status for existing servers + // Patch any row whose name/url/type/enabled diverged from the in-memory + // entry. `updateServer` redials when enabled so a URL or transport change + // actually takes effect (the previous sync only handled enable toggles, + // leaving editors connected to the old endpoint). for (const dbServer of dbServers) { const providerServer = servers.find((s) => s.id === dbServer.id) - if (providerServer && providerServer.enabled !== (dbServer.enabled === 1)) { - updateServerStatus(dbServer.id, dbServer.enabled === 1) + if (!providerServer) { + continue + } + const next = { + id: dbServer.id, + name: dbServer.name ?? '', + url: dbServer.url || '', + type: dbServer.type === 'sse' ? ('sse' as const) : ('http' as const), + enabled: dbServer.enabled === 1, + } + if ( + providerServer.name !== next.name || + providerServer.url !== next.url || + providerServer.type !== next.type || + providerServer.enabled !== next.enabled + ) { + updateServer(next) } } } syncServers() - }, [dbServers, servers, addServer, removeServer, updateServerStatus]) + }, [dbServers, servers, addServer, removeServer, updateServer]) return { servers, dbServers } } diff --git a/src/lib/mcp-provider.test.tsx b/src/lib/mcp-provider.test.tsx index e576faba7..af9399b94 100644 --- a/src/lib/mcp-provider.test.tsx +++ b/src/lib/mcp-provider.test.tsx @@ -207,7 +207,7 @@ describe('MCPProvider reconnect', () => { await act(async () => { const pending = result.current.addServer(server) // Disable the server while the initial connect's createClient is in flight. - result.current.updateServerStatus(server.id, false) + result.current.updateServer({ ...server, enabled: false }) gate.resolve() await pending }) @@ -217,9 +217,9 @@ describe('MCPProvider reconnect', () => { expect(result.current.getEnabledClients()).toHaveLength(0) }) - // UNIT 1 (#2): the addServer→connect vs updateServerStatus(enable)→connect - // race. Without coalescing the second connect would cacheClient over the - // first live client without closing it, leaking the first connection. + // UNIT 1 (#2): the addServer→connect vs updateServer(enable)→connect race. + // Without coalescing the second connect would cacheClient over the first + // live client without closing it, leaking the first connection. it('coalesces overlapping connects for the same server (no leaked client)', async () => { const created: Array> = [] const gate = deferred() @@ -233,9 +233,9 @@ describe('MCPProvider reconnect', () => { const { result } = renderProvider(createClient) await act(async () => { - // addServer fires the first connect; a concurrent enable re-fires it. + // addServer fires the first connect; a concurrent enable patch re-fires it. const a = result.current.addServer(server) - result.current.updateServerStatus(server.id, true) + result.current.updateServer({ ...server, enabled: true }) gate.resolve() await a }) @@ -276,7 +276,7 @@ describe('MCPProvider reconnect', () => { await act(async () => { const pending = result.current.reconnectServer(server.id) // Disable the server while the reconnect's createClient is still pending. - result.current.updateServerStatus(server.id, false) + result.current.updateServer({ ...server, enabled: false }) gate.resolve() await pending }) @@ -320,6 +320,81 @@ describe('MCPProvider reconnect', () => { expect(after[0].client).toBe(reconnected as unknown as ProviderMCPClient) expect(after[0].client).not.toBe(initial as unknown as ProviderMCPClient) }) + + // Settings can edit a server in-place (rename, new url/transport, new bearer + // token). The provider had no patch path, so the live client kept dialing the + // old endpoint while the UI showed the new config — `updateServer` closes the + // stale client, patches state, and redials so a save actually takes effect. + it('patches url+name and reconnects the live client on updateServer', async () => { + const initial = fakeClient() + const refreshed = fakeClient() + let calls = 0 + const seenArgs: Array<{ url: string }> = [] + const createClient = async (_id: string, url: string): Promise => { + calls++ + seenArgs.push({ url }) + return calls === 1 ? initial : refreshed + } + + const { result } = renderProvider(createClient) + + await act(async () => { + await result.current.addServer(server) + }) + expect(seenArgs[0].url).toBe(server.url) + + await act(async () => { + result.current.updateServer({ ...server, name: 'Renamed', url: 'https://new.test/mcp' }) + // Reconnect kicks off synchronously; await its in-flight promise via a + // follow-up reconnectServer call (coalesces into the same promise). + await result.current.reconnectServer(server.id) + }) + + // Stale client closed, fresh one cached with the new URL fed to createClient. + expect(initial.closeCount()).toBe(1) + expect(seenArgs[1].url).toBe('https://new.test/mcp') + const after = result.current.servers.find((s) => s.id === server.id) + expect(after?.name).toBe('Renamed') + expect(after?.url).toBe('https://new.test/mcp') + expect(after?.client).toBe(refreshed as unknown as ProviderMCPClient) + }) + + it('disconnects without redialing when updateServer flips enabled to false', async () => { + const initial = fakeClient() + let calls = 0 + const createClient = async (): Promise => { + calls++ + return initial + } + + const { result } = renderProvider(createClient) + + await act(async () => { + await result.current.addServer(server) + }) + expect(calls).toBe(1) + + await act(async () => { + result.current.updateServer({ ...server, enabled: false }) + }) + + // No second createClient (no reconnect on a disabled patch) and the old client closed. + expect(calls).toBe(1) + expect(initial.closeCount()).toBe(1) + const after = result.current.servers.find((s) => s.id === server.id) + expect(after?.enabled).toBe(false) + expect(after?.client).toBeNull() + expect(after?.isConnected).toBe(false) + }) + + it('is a no-op when updateServer targets an unknown id', () => { + const createClient = async (): Promise => fakeClient() + const { result } = renderProvider(createClient) + + // No throw, no spurious entry inserted. + act(() => result.current.updateServer({ ...server, id: 'unknown' })) + expect(result.current.servers.some((s) => s.id === 'unknown')).toBe(false) + }) }) // `resolveMcpAccessToken` is the per-connect credential→token resolution that diff --git a/src/lib/mcp-provider.tsx b/src/lib/mcp-provider.tsx index 67afa5d45..80fd775a3 100644 --- a/src/lib/mcp-provider.tsx +++ b/src/lib/mcp-provider.tsx @@ -49,7 +49,14 @@ type MCPContextType = { reconnectClient: ReconnectClient addServer: (server: MCPServer) => Promise removeServer: (serverId: string) => void - updateServerStatus: (serverId: string, enabled: boolean) => void + /** Apply a row patch (rename / url / type / enabled) and reconcile the + * connection: disabled → disconnect; disabled→enabled → connect (coalesces + * with any in-flight initial connect); already-enabled → reconnect (closes + * the stale client, redials with the new url/type and live credentials). + * Credentials (bearer or OAuth) live in `mcp_secrets`; `defaultCreateClient` + * re-reads them on every connect, so the redial picks up the new value too. + * No-op when the id isn't tracked yet. */ + updateServer: (server: MCPServer) => void } const MCPContext = createContext(undefined) @@ -150,7 +157,7 @@ export const MCPProvider = ({ children, createClient: injectedCreateClient }: MC // Coalesce overlapping connects for the same server into one in-flight // promise. Two sync consumers can re-fire the enable→connect path before - // React flushes (the addServer→connect vs updateServerStatus(enable)→connect + // React flushes (the addServer→connect vs updateServer(enable)→connect // race), so without this a second createClient could cacheClient over a live // client without closing it — leaking the first connection. const inFlight = connectsInFlight.current.get(server.id) @@ -255,22 +262,46 @@ export const MCPProvider = ({ children, createClient: injectedCreateClient }: MC commitServers((prev) => prev.filter((s) => s.id !== serverId)) } - const updateServerStatus = (serverId: string, enabled: boolean) => { - const server = serversRef.current.find((s) => s.id === serverId) - if (!server) { + /** Apply a row patch from settings or PowerSync sync and reconcile the live + * connection. Disabled → disconnect; disabled→enabled → connectServer (so + * it coalesces with an in-flight initial connect via `connectsInFlight`); + * already-enabled → reconnectServer, which closes the stale client and + * redials with the new url/type plus freshly-read credentials (mcp_secrets + * changes don't touch the row, so we always redial here even when fields + * are unchanged). The in-flight-with-matching-url guard suppresses a second + * createClient when an initial connect for the same target is already + * underway. */ + const updateServer = (server: MCPServer) => { + const existing = serversRef.current.find((s) => s.id === server.id) + if (!existing) { return } - if (enabled) { - connectServer({ ...server, enabled }) - } else { - disconnectServer(serverId) + commitServers((prev) => + prev.map((s) => + s.id === server.id + ? { ...s, name: server.name, url: server.url, type: server.type, enabled: server.enabled } + : s, + ), + ) + + if (!server.enabled) { + disconnectServer(server.id) commitServers((prev) => - prev.map((s) => - s.id === serverId ? { ...s, client: null, isConnected: false, error: null, enabled: false } : s, - ), + prev.map((s) => (s.id === server.id ? { ...s, client: null, isConnected: false, error: null } : s)), ) + return + } + + if (!existing.enabled) { + void connectServer(server) + return + } + + if (connectsInFlight.current.has(server.id) && existing.url === server.url && existing.type === server.type) { + return } + void reconnectServer(server.id) } /** Open a fresh connection for `serverId`, committing it only if the server is @@ -373,7 +404,7 @@ export const MCPProvider = ({ children, createClient: injectedCreateClient }: MC reconnectClient, addServer, removeServer, - updateServerStatus, + updateServer, }} > {children} 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..e6b9e04af 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' @@ -191,7 +192,7 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep // Read provider connection state read-only for status display. Sync ownership // lives in the single global useMcpSync() in AppContent — running it here too // would re-run the reconciliation effect and double-register servers. - const { servers: mcpServers, reconnectServer } = useMCP() + const { servers: mcpServers, reconnectServer, updateServer } = useMCP() const location = useLocation() const navigate = useNavigate() const [mode, setMode] = useState('simple') @@ -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,39 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep resetLocalDialogState() } + const handleUpdateServer = async () => { + if (!form.editingServerId || !newServerUrl || !isUrlValid) { + return + } + const id = form.editingServerId + const name = resolveServerName() + const transport = newServerTransport + // Carry the current enabled flag through. The row's enabled bit isn't part + // of this dialog, so we mirror the in-memory state to avoid an inadvertent + // toggle. Falls back to enabled=true for a defensively-missing entry. + const existing = mcpServers.find((s) => s.id === id) + const enabled = existing?.enabled ?? true + await updateServerMutation.mutateAsync({ + id, + name, + url: newServerUrl, + transport, + token: newServerToken, + originalCredentialType: credentialTypeById[id] ?? 'none', + }) + // Push the patch into the MCP provider so the live client redials with the + // new url/type/credentials. useMcpSync would catch row changes eventually + // via PowerSync, but credential-only edits don't touch the row at all — + // updateServer's reconnect re-reads `mcp_secrets` so both paths converge. + updateServer({ id, name, url: newServerUrl, type: transport, enabled }) + 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 @@ -426,15 +503,24 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep } const handleUrlKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - if (testResult.kind === 'idle' && newServerUrl && isUrlValid) { - testConnection() - } else if (testResult.kind === 'success') { - handleAddServer() - } else if (testResult.kind === 'needs-oauth') { - handleAddAndAuthorize() - } + if (e.key !== 'Enter') { + return + } + e.preventDefault() + // Metadata-only edit (rename / no connection change): Save Changes is the + // button's enabled state regardless of testResult, so Enter must mirror it + // — otherwise pressing Enter after a rename does nothing while the button + // would have saved. + if (form.editingServerId && !form.hasConnectionEdits && newServerUrl && isUrlValid) { + handleUpdateServer() + return + } + if (testResult.kind === 'idle' && newServerUrl && isUrlValid) { + testConnection() + } else if (testResult.kind === 'success') { + form.editingServerId ? handleUpdateServer() : handleAddServer() + } else if (testResult.kind === 'needs-oauth' && !form.editingServerId) { + handleAddAndAuthorize() } } @@ -586,30 +672,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 +838,22 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep > Cancel - {mode === 'advanced' ? ( + {form.editingServerId ? ( + + ) : mode === 'advanced' ? ( @@ -908,6 +1014,22 @@ export default function McpServersPage({ deps = {} }: { deps?: McpServersPageDep

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

+ + + + + +

Edit server

+
+
setDeleteConfirmOpen(open ? server.id : null)}