From 95fbd06dc30f02b484e94419537a8da826ddccbd Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 6 Sep 2026 15:41:15 +0300 Subject: [PATCH] fix(settings): share MCP mutation queues across editors --- src/app/integrations/mcp/mutations.ts | 22 ++++++++ src/app/integrations/mcp/settings/use.ts | 43 +++++++++++---- .../engine/app/settings/mcp-workflow.test.ts | 52 +++++++++++++++++++ 3 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 src/app/integrations/mcp/mutations.ts diff --git a/src/app/integrations/mcp/mutations.ts b/src/app/integrations/mcp/mutations.ts new file mode 100644 index 000000000..f461f85c6 --- /dev/null +++ b/src/app/integrations/mcp/mutations.ts @@ -0,0 +1,22 @@ +import type { MCPConnectionID } from './types' + +// Domain-owned: pending operations survive disposal of a Settings editor. +const pendingByConnection = new Map>() + +export function enqueueMCPConnectionMutation( + id: MCPConnectionID, + operation: () => Promise +): Promise { + const previous = pendingByConnection.get(id) ?? Promise.resolve() + const pending = previous.then(operation, operation) + const settled = pending.then( + () => undefined, + () => undefined + ) + pendingByConnection.set(id, settled) + void settled.then(() => { + if (pendingByConnection.get(id) === settled) pendingByConnection.delete(id) + return undefined + }) + return pending +} diff --git a/src/app/integrations/mcp/settings/use.ts b/src/app/integrations/mcp/settings/use.ts index 781657c08..7244dc870 100644 --- a/src/app/integrations/mcp/settings/use.ts +++ b/src/app/integrations/mcp/settings/use.ts @@ -12,6 +12,9 @@ import { } from '@/app/integrations/mcp' import type { CredentialStatus } from '@/app/settings/credentials/types' +import { enqueueMCPConnectionMutation } from '../mutations' +import type { MCPConnectionID } from '../types' + const connectionServices = { status: mcpConnectionCredentialStatus, save: saveMCPConnectionDraft, @@ -27,18 +30,23 @@ export function useMCPConnectionSettings( const draft = ref(createMCPConnectionDraft()) const tokenStatus = ref('missing') const error = ref('') + let version = 0 let disposed = false + tryOnScopeDispose(() => { disposed = true version++ }) + const savedConnection = computed(() => mcpConnectionSettings.value.connections.find((connection) => connection.id === draft.value.id) ) + function current(request: number): boolean { return !disposed && request === version } + function startAdd(): void { version++ draft.value = createMCPConnectionDraft() @@ -46,36 +54,39 @@ export function useMCPConnectionSettings( tokenStatus.value = 'missing' error.value = '' } + async function startEdit(id: string): Promise { const connection = mcpConnectionSettings.value.connections.find((item) => item.id === id) if (!connection) return false + const request = ++version draft.value = createMCPConnectionDraft(connection) tokenDraft.value = '' tokenStatus.value = 'missing' error.value = '' + try { const status = await services.status(connection.id) if (!current(request)) return false + tokenStatus.value = status return true } catch (cause) { if (!current(request)) return false + error.value = cause instanceof Error ? cause.message : String(cause) return true } } - let mutationQueue: Promise = Promise.resolve() - function enqueue(operation: () => Promise): Promise { - const pending = mutationQueue.then(operation, operation) - mutationQueue = pending.catch(() => undefined) - return pending - } + async function save(): Promise { const request = ++version - const target = { ...draft.value } + const id: MCPConnectionID = draft.value.id ?? `mcp-${crypto.randomUUID()}` + draft.value.id = id + const target = { ...draft.value, id } const token = tokenDraft.value error.value = '' + try { if ( target.enabled && @@ -85,13 +96,15 @@ export function useMCPConnectionSettings( ) { throw new Error(automation.value.bearerTokenRequired) } - await enqueue(async () => { + + await enqueueMCPConnectionMutation(id, async () => { const connection = services.save({ ...target, enabled: false }) if (target.authenticationType === 'none') await services.setCredential(connection.id, '') else if (token.trim()) await services.setCredential(connection.id, token) services.save({ ...target, id: connection.id }) }) if (!current(request)) return false + if (tokenDraft.value === token) tokenDraft.value = '' return true } catch (cause) { @@ -99,20 +112,25 @@ export function useMCPConnectionSettings( return false } } + async function clearCredential(): Promise { const id = draft.value.id if (!id) return + const request = ++version const token = tokenDraft.value error.value = '' + try { - await enqueue(async () => { + await enqueueMCPConnectionMutation(id, async () => { const connection = mcpConnectionSettings.value.connections.find((item) => item.id === id) if (!connection) throw new Error('Connection no longer exists') + services.save({ ...createMCPConnectionDraft(connection), enabled: false }) await services.setCredential(id, '') }) if (!current(request)) return + draft.value.enabled = false if (tokenDraft.value === token) tokenDraft.value = '' tokenStatus.value = 'missing' @@ -120,25 +138,30 @@ export function useMCPConnectionSettings( if (current(request)) error.value = cause instanceof Error ? cause.message : String(cause) } } + async function remove(): Promise { const id = draft.value.id if (!id) return false + const request = ++version error.value = '' + try { - await enqueue(() => services.remove(id)) + await enqueueMCPConnectionMutation(id, () => services.remove(id)) return current(request) } catch (cause) { if (current(request)) error.value = cause instanceof Error ? cause.message : String(cause) return false } } + watch( () => draft.value.authenticationType, (type) => { if (type === 'none') tokenDraft.value = '' } ) + return { draft, tokenStatus, diff --git a/tests/engine/app/settings/mcp-workflow.test.ts b/tests/engine/app/settings/mcp-workflow.test.ts index d1b18d507..108880cd9 100644 --- a/tests/engine/app/settings/mcp-workflow.test.ts +++ b/tests/engine/app/settings/mcp-workflow.test.ts @@ -228,3 +228,55 @@ test('serializes overlapping credential replacements', async () => { scope.stop() } }) + +test('reopened editors serialize writes to the same connection while other connections proceed', async () => { + const firstScope = effectScope() + const secondScope = effectScope() + let finish: () => void = () => undefined + const blocked = new Promise((resolve) => { + finish = resolve + }) + const writes: string[] = [] + const services = { + status: async () => 'configured' as const, + setCredential: async (_id: string, value: string) => { + writes.push(value) + if (value === 'first') await blocked + }, + save: (draft: { id: `mcp-${string}` | null }) => ({ + ...connection, + id: draft.id ?? connection.id + }), + remove: async () => undefined + } + try { + const first = firstScope.run(() => + useMCPConnectionSettings(ref('first'), ref({ bearerTokenRequired: 'Required' }), services) + ) + const second = secondScope.run(() => + useMCPConnectionSettings(ref('second'), ref({ bearerTokenRequired: 'Required' }), services) + ) + const other = secondScope.run(() => + useMCPConnectionSettings(ref('other'), ref({ bearerTokenRequired: 'Required' }), services) + ) + if (!first || !second || !other) throw new Error('Missing scope') + for (const editor of [first, second, other]) { + editor.draft.value.id = connection.id + editor.draft.value.authenticationType = 'bearer' + } + other.draft.value.id = 'mcp-other' + const savingFirst = first.save() + await Promise.resolve() + firstScope.stop() + const savingSecond = second.save() + await other.save() + expect(writes).toEqual(['first', 'other']) + finish() + await Promise.all([savingFirst, savingSecond]) + expect(writes).toEqual(['first', 'other', 'second']) + } finally { + finish() + firstScope.stop() + secondScope.stop() + } +})