Skip to content

Commit c17051c

Browse files
thesaadmirzaSaad ur Rehman
andauthored
feat(mcp): persist MCP servers in a database table (#13976)
* feat(mcp): persist MCP servers in a database table MCP servers were stored in a per-user JSON file guarded by an in-process lock, so concurrent edits lose updates and the list diverges across workers/replicas. Store them in a new mcp_server table (one row per server, unique on (user_id, name)); get_server_list/get_server/update_server become per-row reads/upserts and the in-process lock is removed, so MCP config is safe at any worker/replica count. Secret values (env/headers) are encrypted at rest. Existing _mcp_servers_*.json files are imported by an idempotent startup backfill and a `langflow migrate-mcp` CLI; the file is kept for rollback. REST shape and function signatures are unchanged. Fixes #13970 * fix(mcp): clear hashed cache-key variants when a server changes The shared tool cache is keyed {server_name}:{hash of headers+timeout} (MCPComponent._mcp_servers_cache_key), so clearing only the bare server name left servers with custom headers/timeouts serving stale config after an update. Clear the bare name and all {server_name}: variants. * fix(mcp): re-apply create/merge rules after IntegrityError refetch The IntegrityError fallback in update_server reused the pre-race config, so a concurrent same-name create (check_existing) could overwrite the winning row and a concurrent PATCH (merge_existing) skipped the merge. Now re-apply the rules against the refetched row: check_existing raises 'already exists', merge_existing merges, otherwise overwrite. Adds a concurrency test. * fix(mcp): order get_server_list by created_at to preserve insertion order get_server_list had no ORDER BY, so with several rows the server list order was undefined. The legacy file (a dict) preserved insertion order and the UI relies on it (the starter project must stay first). Order by created_at to restore stable insertion order. * fix(mcp): re-point migration onto current base head to resolve alembic multi-head release-1.11.0 gained migration c3e7a1b9d2f4 after this branch opened. Our migration also chained off 4f0d2c9a8b7e, producing two alembic heads so 'alembic upgrade head' failed and the DB never initialized (cascading to all e2e/integration/docker jobs). Re-point down_revision from 4f0d2c9a8b7e to c3e7a1b9d2f4 so the chain is linear with a single head. --------- Co-authored-by: Saad ur Rehman <saad.urrehman@cleura.com>
1 parent c4db860 commit c17051c

11 files changed

Lines changed: 775 additions & 101 deletions

File tree

src/backend/base/langflow/__main__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,33 @@ async def _create_superuser(username: str, password: str, auth_token: str | None
975975
typer.echo("Superuser creation failed.")
976976

977977

978+
@app.command(name="migrate-mcp")
979+
def migrate_mcp(
980+
log_level: str = typer.Option("info", help="Logging level.", envvar="LANGFLOW_LOG_LEVEL"),
981+
dry_run: bool = typer.Option(default=False, help="Report what would be imported without writing."), # noqa: FBT001
982+
) -> None:
983+
"""Import existing per-user MCP config files (_mcp_servers_<id>.json) into the mcp_server table.
984+
985+
This also runs automatically on startup; use this command to run or preview it on demand.
986+
It is idempotent and never deletes the legacy files.
987+
"""
988+
configure(log_level=log_level)
989+
asyncio.run(_migrate_mcp(dry_run=dry_run))
990+
991+
992+
async def _migrate_mcp(*, dry_run: bool) -> None:
993+
from langflow.api.utils.mcp.backfill import backfill_mcp_servers_from_files
994+
995+
await initialize_services()
996+
async with session_scope() as session:
997+
summary = await backfill_mcp_servers_from_files(session, dry_run=dry_run)
998+
verb = "would import" if dry_run else "imported"
999+
typer.echo(
1000+
f"MCP migration complete: {verb} {summary['imported']} server(s) across "
1001+
f"{summary['users']} user(s) (skipped {summary['skipped']}, errors {summary['errors']})."
1002+
)
1003+
1004+
9781005
# command to copy the langflow database from the cache to the current directory
9791006
# because now the database is stored per installation
9801007
@app.command()
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""add mcp_server table
2+
3+
Revision ID: 247308ce2598
4+
Revises: c3e7a1b9d2f4
5+
Create Date: 2026-07-06 17:10:00.000000
6+
7+
Phase: EXPAND
8+
"""
9+
10+
from collections.abc import Sequence
11+
12+
import sqlalchemy as sa
13+
import sqlmodel
14+
from alembic import op
15+
from langflow.utils import migration
16+
17+
# revision identifiers, used by Alembic.
18+
revision: str = "247308ce2598" # pragma: allowlist secret
19+
down_revision: str | None = "c3e7a1b9d2f4" # pragma: allowlist secret
20+
branch_labels: str | Sequence[str] | None = None
21+
depends_on: str | Sequence[str] | None = None
22+
23+
24+
def upgrade() -> None:
25+
conn = op.get_bind()
26+
if migration.table_exists("mcp_server", conn):
27+
return
28+
29+
op.create_table(
30+
"mcp_server",
31+
sa.Column("id", sa.Uuid(), nullable=False),
32+
sa.Column("user_id", sa.Uuid(), nullable=False),
33+
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
34+
sa.Column("transport", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
35+
sa.Column("config", sa.JSON(), nullable=True),
36+
sa.Column("enabled", sa.Boolean(), nullable=False),
37+
sa.Column("version", sa.Integer(), nullable=False),
38+
sa.Column("created_at", sa.DateTime(), nullable=False),
39+
sa.Column("updated_at", sa.DateTime(), nullable=False),
40+
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
41+
sa.PrimaryKeyConstraint("id"),
42+
sa.UniqueConstraint("user_id", "name", name="uq_mcp_server_name_user"),
43+
)
44+
45+
46+
def downgrade() -> None:
47+
conn = op.get_bind()
48+
if not migration.table_exists("mcp_server", conn):
49+
return
50+
51+
op.drop_table("mcp_server")
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Backfill MCP servers from the legacy per-user JSON file into the mcp_server table.
2+
3+
Runs best-effort on startup and via ``langflow migrate-mcp``. Properties:
4+
5+
- **Idempotent** — only inserts a server that isn't already a row for that user, so
6+
it is safe to run on every boot and to re-run by hand.
7+
- **Multi-replica safe** — the ``(user_id, name)`` unique constraint is the backstop;
8+
a concurrent insert from another replica raises ``IntegrityError`` which is caught.
9+
- **Backend-agnostic** — reads through the storage service, so it covers local disk
10+
and S3/Ceph/MinIO identically.
11+
- **Non-destructive** — the legacy file is never deleted, so rolling back to a
12+
file-based Langflow remains safe.
13+
"""
14+
15+
from sqlalchemy.exc import IntegrityError
16+
from sqlmodel import select
17+
18+
from langflow.api.v2.mcp import _read_legacy_mcp_file
19+
from langflow.logging import logger
20+
from langflow.services.auth.mcp_encryption import encrypt_mcp_config
21+
from langflow.services.database.models import MCPServer
22+
from langflow.services.database.models.user.model import User
23+
from langflow.services.deps import get_settings_service, get_storage_service
24+
25+
26+
async def backfill_mcp_servers_from_files(session, *, dry_run: bool = False) -> dict[str, int]:
27+
"""Import each user's legacy ``_mcp_servers_<id>.json`` entries into ``mcp_server``.
28+
29+
Args:
30+
session: An async DB session.
31+
dry_run: If True, count what would be imported without writing.
32+
33+
Returns:
34+
Summary counts: ``{"users", "imported", "skipped", "errors"}``.
35+
"""
36+
storage_service = get_storage_service()
37+
settings_service = get_settings_service()
38+
summary = {"users": 0, "imported": 0, "skipped": 0, "errors": 0}
39+
40+
users = (await session.exec(select(User))).all()
41+
for user in users:
42+
summary["users"] += 1
43+
try:
44+
legacy = await _read_legacy_mcp_file(
45+
user, session, storage_service, settings_service, create_if_missing=False
46+
)
47+
except Exception as e: # noqa: BLE001
48+
summary["errors"] += 1
49+
await logger.awarning(f"MCP backfill: could not read legacy MCP file for user {user.id}: {e}")
50+
continue
51+
52+
pending = 0
53+
for name, config in (legacy.get("mcpServers") or {}).items():
54+
already = (
55+
await session.exec(select(MCPServer).where(MCPServer.user_id == user.id, MCPServer.name == name))
56+
).first()
57+
if already is not None:
58+
summary["skipped"] += 1
59+
continue
60+
summary["imported"] += 1
61+
if dry_run:
62+
continue
63+
session.add(MCPServer(user_id=user.id, name=name, config=encrypt_mcp_config(config or {})))
64+
pending += 1
65+
66+
if pending:
67+
try:
68+
await session.commit()
69+
except IntegrityError:
70+
# Another replica imported this user's servers first; safe to skip.
71+
await session.rollback()
72+
73+
await logger.ainfo(
74+
"MCP backfill complete: users=%s imported=%s skipped=%s errors=%s",
75+
summary["users"],
76+
summary["imported"],
77+
summary["skipped"],
78+
summary["errors"],
79+
)
80+
return summary

0 commit comments

Comments
 (0)