Skip to content

postgres-mcp-server: startup self-test binds the connection's aiorwlock to a throwaway event loop, crashing the first real query (issue #2505 item #4) #4380

Description

@modosc

summary

main()'s startup self-test runs run_query('SELECT 1') inside asyncio.run(), then mcp.run() serves real requests on a separate loop. aiorwlock.RWLock binds lazily to whichever loop first acquires it, so the self-test's connection stays bound to that throwaway loop. every first real run_query/get_table_schema call after a successful startup crashes with:

<aiorwlock._RWLockCore object at ...> is bound to a different event loop

this is item 4 of #2505, bundled with four unrelated issues. filing separately since it has its own root cause and fix, and #2504's proposed fix has a correctness problem worth its own thread.

where this lives (current main, f984f58f)

server.py:1206-1233 (DummyCtx at line 88):

if db_connection:
    ctx = DummyCtx()
    response = asyncio.run(
        run_query('SELECT 1', ctx, ConnectionMethod[args.connection_method],
                  cluster_identifier, args.db_endpoint, args.database)
    )
    ...

logger.info('Postgres MCP server started')
mcp.run()

asyncio.run() runs the self-test to completion and tears the loop down. mcp.run() then serves on a different loop. the connection survives in db_connection_map, but its aiorwlock.RWLock doesn't. every cluster, every time, on 1.1.7 and current main.

why #2504 leaks a connection

#2504's fix (discard the self-test's connection, auto-reconnect lazily in run_query's cache-miss path) is the obvious first attempt, and what i tried too.

  • close() acquires the same dead-loop-bound rwlock, raises the same RuntimeError, swallowed by a broad except. does nothing.
  • that leaves clear() (drop the reference, don't close). but psycopg_pool.AsyncConnectionPool's idle-reaper task (ShrinkPool) is scheduled on the self-test's loop and dies with it. nothing ever closes the underlying connection.

verified against a live Aurora cluster: with this fix, every server start leaves one orphaned idle backend (query = COMMIT, state_change matching startup time), one found idle 17.5 hours later. against a role with a small CONNECTION LIMIT (typical for IAM-auth users), repeated restarts exhaust it from orphans alone, independent of actual query load.

proposed fix: run the self-test in the same loop mcp.run() serves on

mcp.run() is just anyio.run(self.run_stdio_async) for stdio. run_stdio_async is already a public coroutine, so nothing requires the self-test and the serving loop to be separate asyncio.run() calls:

async def _run_server():
    if args.db_type:
        cluster_identifier = args.db_cluster_arn.split(':')[-1]
        db_connection, llm_response = internal_create_connection(
            region=args.region, database_type=DatabaseType[args.db_type],
            connection_method=ConnectionMethod[args.connection_method],
            cluster_identifier=cluster_identifier, db_endpoint=args.db_endpoint,
            port=args.port, database=args.database,
        )
        if db_connection:
            ctx = DummyCtx()
            response = await run_query(
                'SELECT 1', ctx, ConnectionMethod[args.connection_method],
                cluster_identifier, args.db_endpoint, args.database,
            )
            if (isinstance(response, list) and len(response) == 1
                    and isinstance(response[0], dict) and 'error' in response[0]):
                logger.error('Failed to validate database connection to Postgres. Exit the MCP server')
                sys.exit(1)
            else:
                logger.success('Successfully validated database connection to Postgres')

    logger.info('Postgres MCP server started')
    await mcp.run_stdio_async()

try:
    asyncio.run(_run_server())
finally:
    db_connection_map.close_all()

happy to open a PR with this fix, either replacing the relevant part of #2504 or standalone.

simpler fallback, if the async restructure is unwanted: delete the self-test's query entirely and rely on internal_create_connection()'s existing sync AWS-level validation (still raises on a bad ARN/region). trades away fail-fast-on-bad-database for a smaller diff. tried this before landing on the fix above; can share it if useful, but the single-loop fix is what i'd actually recommend.

how i verified it

the crash only reproduces on the second call in a real session, a one-shot CLI self-test never exercises the second loop. tested with an actual mcp client (ClientSession/stdio_client) through initialize()run_query()run_query() against a live Aurora cluster.

  • before: first real query crashes right after a successful, logged startup validation.
  • after: self-test logs success, both real queries succeed identically.
  • fail-fast resolves cleanly: pointed at a nonexistent database, pool.connection(timeout=15.0) raises PoolTimeout after 15s, run_query wraps it into an error response, the self-test catches it and sys.exit(1)s at ~18.5s total. bounded, well under the 30s MCP client timeout, not a hang.
  • no leak: checked pg_stat_activity right after three queries (two connections open) and again after ~34 min idle. both eventually closed, the self-test's connection slower to reap (~32-35 min) than an ad-hoc one (~15-20 min), but nowhere near the 17.5 hour permanent orphan from the discard-and-reconnect design.
  • unit tests, ruff, pyright all pass.

reproduction

uvx awslabs.postgres-mcp-server@latest \
  --region=us-east-1 --db_type=APG --db_cluster_arn=<arn> \
  --db_endpoint=<endpoint> --database=<db> --connection_method=PG_WIRE_IAM_PROTOCOL

connect a real MCP client and call run_query twice in the same session. the successful startup log isn't proof, only a second live call through the actual serving loop is.

environment

  • postgres-mcp-server: reproduced on current main (f984f58f) and published 1.1.7
  • connection method: PG_WIRE_IAM_PROTOCOL (also affects PG_WIRE_PROTOCOL/RDS_API, any path reaching the self-test's run_query)
  • AWS: Aurora PostgreSQL, IAM authentication

related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    To triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions