Skip to content

Commit d021b3e

Browse files
thesaadmirzaSaad ur Rehmanautofix-ci[bot]erichare
authored
fix(mcp): optimistic-lock concurrent MCP server PATCHes (#14005)
* fix(mcp): optimistic-lock concurrent MCP server PATCHes Follow-up to #13976. A concurrent merge PATCH to the same MCP server could still lose a field: two PATCHes read the same row and the last commit wins, dropping the other's change. Guard the merge-PATCH write with the existing version column (UPDATE ... WHERE id=? AND version=?); on a 0-row result, re-read, re-merge, and retry. A full replace stays last-writer-wins (unconditional update) so it never contends and cannot exhaust the retry budget. Adds a regression test running concurrent distinct-field PATCHes that asserts all survive. * fix(mcp): return 404/409 instead of 500 for not-found/conflict Per review on #14005: a concurrently-deleted server during a merge PATCH, a missing server on delete, and a duplicate on create are client outcomes, not server faults. Return 404 (not found) and 409 (conflict) so they don't trip 5xx error handling. Applied consistently across update_server. * fix(mcp): address review feedback on the optimistic-lock PATCH Per review of #14005: (1) full replace bumps the version DB-side (version = MCPServer.version + 1) so it stays monotonic even off a stale ORM read, instead of reusing a version a concurrent PATCH consumed; (2) the create-race handler re-raises when no winning row exists after refetch, so a genuine IntegrityError surfaces instead of retrying to a misleading 409; (3) replaced the probabilistic PATCH-vs-DELETE test with a deterministic one asserting the PATCH returns exactly 404, and added a regression test that injects a concurrent version advance between the replace's read and write to prove monotonicity. * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * fix(mcp): re-create when a full replace races a concurrent delete The merge-PATCH path already turns a row deleted between its read and its guarded UPDATE into a clean 404. The full-replace path never checked rowcount: the same race made its unguarded UPDATE match nothing, the loop broke, and the request returned None with the write silently lost. Check rowcount after the replace UPDATE; on a miss, re-read the row and loop. A still-missing row takes the create path on the next iteration, so the replace converges to a re-create (fresh row, version 1) instead of a silent no-op. Covered by a race-injection test in the same style as test_patch_losing_to_concurrent_delete_returns_404. * [autofix.ci] apply automated fixes --------- Co-authored-by: Saad ur Rehman <saad.urrehman@cleura.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Hare <ericrhare@gmail.com>
1 parent 683eae6 commit d021b3e

5 files changed

Lines changed: 265 additions & 63 deletions

File tree

src/backend/base/langflow/api/v2/mcp.py

Lines changed: 99 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile
77
from lfx.base.agents.utils import safe_cache_get, safe_cache_set
88
from lfx.base.mcp.util import update_tools
9+
from sqlalchemy import update
910
from sqlalchemy.exc import IntegrityError
1011
from sqlmodel import select
1112

@@ -28,6 +29,9 @@
2829

2930
router = APIRouter(tags=["MCP"], prefix="/mcp")
3031

32+
# Retry budget for the version-guarded merge-PATCH path (only same-server merges consume it).
33+
_MAX_UPSERT_RETRIES = 12
34+
3135

3236
def is_mcp_servers_locked(settings: object) -> bool:
3337
"""Return True only when MCP lock is explicitly enabled.
@@ -406,7 +410,7 @@ async def update_server(
406410
server_config: dict,
407411
current_user: CurrentActiveUser,
408412
session: DbSession,
409-
storage_service: Annotated[StorageService, Depends(get_storage_service)],
413+
storage_service: Annotated[StorageService, Depends(get_storage_service)], # noqa: ARG001
410414
settings_service: Annotated[SettingsService, Depends(get_settings_service)],
411415
*,
412416
check_existing: bool = False,
@@ -415,83 +419,121 @@ async def update_server(
415419
):
416420
"""Create, update, or delete one MCP server row for the user.
417421
418-
A single-row upsert on ``(user_id, name)`` replaces the file's non-atomic
419-
read-modify-write: concurrent edits to *different* servers touch different rows
420-
and never contend, so no update is lost at any worker/replica count - without an
421-
in-process lock. A concurrent create of the *same* name is caught by the unique
422-
constraint and folded into an update.
422+
Upserts a single row keyed on ``(user_id, name)`` so concurrent edits to different
423+
servers never contend. A merge PATCH guards its write with the row ``version`` and
424+
retries on conflict, so two concurrent PATCHes to the same server merge instead of
425+
last-writer-wins; a full replace updates unconditionally. ``current_user`` is read
426+
once into ``user_id`` because a later commit/rollback can expire it and re-reading it
427+
would attempt IO in an async context.
423428
"""
429+
user_id = current_user.id
424430
settings = getattr(settings_service, "settings", None)
425431
if not delete:
426432
ensure_mcp_stdio_access(server_config, current_user, settings)
427433

428-
result = await session.exec(
429-
select(MCPServer).where(MCPServer.user_id == current_user.id, MCPServer.name == server_name)
430-
)
431-
existing = result.first()
432-
433434
if delete:
435+
result = await session.exec(
436+
select(MCPServer).where(MCPServer.user_id == user_id, MCPServer.name == server_name)
437+
)
438+
existing = result.first()
434439
if existing is None:
435-
raise HTTPException(status_code=500, detail="Server not found.")
440+
raise HTTPException(status_code=404, detail="Server not found.")
436441
await session.delete(existing)
437442
await session.commit()
438443
_clear_server_cache(server_name)
439444
return None
440445

441-
if check_existing and existing is not None:
442-
raise HTTPException(status_code=500, detail="Server already exists.")
443-
444-
if merge_existing and existing is not None:
445-
# PATCH semantics: shallow-merge the new (partial) config over the existing
446-
# one at the plaintext level, then re-encrypt (mirrors the file store's
447-
# {**existing, **new}).
448-
new_config = {**decrypt_mcp_config(existing.config or {}), **server_config}
449-
else:
450-
new_config = server_config
451-
452-
ensure_mcp_stdio_access(new_config, current_user, settings)
453-
encrypted_config = encrypt_mcp_config(new_config)
454-
transport = _derive_transport(new_config)
446+
result = await session.exec(select(MCPServer).where(MCPServer.user_id == user_id, MCPServer.name == server_name))
447+
existing = result.first()
455448

456-
if existing is None:
457-
session.add(MCPServer(user_id=current_user.id, name=server_name, config=encrypted_config, transport=transport))
458-
else:
459-
existing.config = encrypted_config
460-
existing.transport = transport
461-
existing.version += 1
462-
existing.updated_at = datetime.now(timezone.utc)
463-
session.add(existing)
449+
for _ in range(_MAX_UPSERT_RETRIES):
450+
if check_existing and existing is not None:
451+
raise HTTPException(status_code=409, detail="Server already exists.")
464452

465-
try:
466-
await session.commit()
467-
except IntegrityError:
468-
# A concurrent request created the same (user, name) first (we came in with
469-
# existing=None). Re-apply the create/merge rules against the winning row
470-
# instead of overwriting it with our pre-race config.
471-
await session.rollback()
472-
result = await session.exec(
473-
select(MCPServer).where(MCPServer.user_id == current_user.id, MCPServer.name == server_name)
474-
)
475-
existing = result.first()
476453
if existing is None:
477-
raise
478-
if check_existing:
479-
raise HTTPException(status_code=500, detail="Server already exists.") from None
454+
session.add(
455+
MCPServer(
456+
user_id=user_id,
457+
name=server_name,
458+
config=encrypt_mcp_config(server_config),
459+
transport=_derive_transport(server_config),
460+
)
461+
)
462+
try:
463+
await session.commit()
464+
except IntegrityError:
465+
# The expected IntegrityError here is the duplicate-name race: re-read the
466+
# winner and fall through to the update path. Any other integrity failure
467+
# (e.g. a bad FK) leaves no winning row, so re-raise it instead of masking
468+
# it as retries that end in a misleading 409.
469+
await session.rollback()
470+
result = await session.exec(
471+
select(MCPServer).where(MCPServer.user_id == user_id, MCPServer.name == server_name)
472+
)
473+
existing = result.first()
474+
if existing is None:
475+
raise
476+
continue
477+
break
478+
480479
if merge_existing:
481480
merged = {**decrypt_mcp_config(existing.config or {}), **server_config}
482481
ensure_mcp_stdio_access(merged, current_user, settings)
483-
existing.config = encrypt_mcp_config(merged)
484-
existing.transport = _derive_transport(merged)
485-
else:
486-
existing.config = encrypted_config
487-
existing.transport = transport
488-
existing.version += 1
489-
existing.updated_at = datetime.now(timezone.utc)
490-
session.add(existing)
482+
updated = await session.execute(
483+
update(MCPServer)
484+
.where(MCPServer.id == existing.id, MCPServer.version == existing.version)
485+
.values(
486+
config=encrypt_mcp_config(merged),
487+
transport=_derive_transport(merged),
488+
version=existing.version + 1,
489+
updated_at=datetime.now(timezone.utc),
490+
)
491+
)
492+
await session.commit()
493+
if updated.rowcount == 1:
494+
break
495+
# Version moved under us: expire and re-read so a concurrent delete reads as None.
496+
session.expire(existing)
497+
result = await session.exec(
498+
select(MCPServer).where(MCPServer.user_id == user_id, MCPServer.name == server_name)
499+
)
500+
existing = result.first()
501+
if existing is None:
502+
raise HTTPException(status_code=404, detail="Server not found.")
503+
continue
504+
505+
# Full replace: last-writer-wins on config, but bump the version DB-side
506+
# (version = version + 1) so it stays strictly monotonic even when our ORM copy
507+
# is stale. An ORM `+= 1` off a stale read could reuse a version a concurrent
508+
# PATCH already consumed, letting a later guarded PATCH pass its version check.
509+
replaced = await session.execute(
510+
update(MCPServer)
511+
.where(MCPServer.id == existing.id)
512+
.values(
513+
config=encrypt_mcp_config(server_config),
514+
transport=_derive_transport(server_config),
515+
version=MCPServer.version + 1,
516+
updated_at=datetime.now(timezone.utc),
517+
)
518+
)
491519
await session.commit()
520+
if replaced.rowcount == 1:
521+
break
522+
# Row deleted under us: re-read, and a still-missing row takes the create path
523+
# next iteration — the replace converges to a re-create instead of silently
524+
# returning None with the write lost.
525+
session.expire(existing)
526+
result = await session.exec(
527+
select(MCPServer).where(MCPServer.user_id == user_id, MCPServer.name == server_name)
528+
)
529+
existing = result.first()
530+
else:
531+
raise HTTPException(status_code=409, detail="MCP server was updated concurrently; please retry.")
492532

493533
_clear_server_cache(server_name)
494-
return await get_server(server_name, current_user, session, storage_service, settings_service)
534+
result = await session.exec(select(MCPServer).where(MCPServer.user_id == user_id, MCPServer.name == server_name))
535+
row = result.first()
536+
return decrypt_mcp_config(row.config or {}) if row is not None else None
495537

496538

497539
@router.post("/servers/{server_name}")

0 commit comments

Comments
 (0)