fix(settings): share MCP mutation queues across editors

This commit is contained in:
Danila Poyarkov 2026-09-06 15:41:15 +03:00
parent b5391f7305
commit 95fbd06dc3
3 changed files with 107 additions and 10 deletions

View file

@ -0,0 +1,22 @@
import type { MCPConnectionID } from './types'
// Domain-owned: pending operations survive disposal of a Settings editor.
const pendingByConnection = new Map<MCPConnectionID, Promise<unknown>>()
export function enqueueMCPConnectionMutation<T>(
id: MCPConnectionID,
operation: () => Promise<T>
): Promise<T> {
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
}

View file

@ -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<MCPConnectionDraft>(createMCPConnectionDraft())
const tokenStatus = ref<CredentialStatus>('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<boolean> {
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<unknown> = Promise.resolve()
function enqueue<T>(operation: () => Promise<T>): Promise<T> {
const pending = mutationQueue.then(operation, operation)
mutationQueue = pending.catch(() => undefined)
return pending
}
async function save(): Promise<boolean> {
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<void> {
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<boolean> {
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,

View file

@ -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<void>((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()
}
})