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
summary
main()'s startup self-test runsrun_query('SELECT 1')insideasyncio.run(), thenmcp.run()serves real requests on a separate loop.aiorwlock.RWLockbinds lazily to whichever loop first acquires it, so the self-test's connection stays bound to that throwaway loop. every first realrun_query/get_table_schemacall after a successful startup crashes with: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(DummyCtxat line 88):asyncio.run()runs the self-test to completion and tears the loop down.mcp.run()then serves on a different loop. the connection survives indb_connection_map, but itsaiorwlock.RWLockdoesn't. every cluster, every time, on1.1.7and currentmain.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 sameRuntimeError, swallowed by a broad except. does nothing.clear()(drop the reference, don't close). butpsycopg_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_changematching startup time), one found idle 17.5 hours later. against a role with a smallCONNECTION 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 onmcp.run()is justanyio.run(self.run_stdio_async)for stdio.run_stdio_asyncis already a public coroutine, so nothing requires the self-test and the serving loop to be separateasyncio.run()calls: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
mcpclient (ClientSession/stdio_client) throughinitialize()→run_query()→run_query()against a live Aurora cluster.pool.connection(timeout=15.0)raisesPoolTimeoutafter 15s,run_querywraps it into an error response, the self-test catches it andsys.exit(1)s at ~18.5s total. bounded, well under the 30s MCP client timeout, not a hang.pg_stat_activityright 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.ruff,pyrightall pass.reproduction
connect a real MCP client and call
run_querytwice in the same session. the successful startup log isn't proof, only a second live call through the actual serving loop is.environment
main(f984f58f) and published1.1.7PG_WIRE_IAM_PROTOCOL(also affectsPG_WIRE_PROTOCOL/RDS_API, any path reaching the self-test'srun_query)related
initialize_poolcrash subprocess during pool refresh #3530 (separate, now-fixed event-loop issue in the same connection-pooling module, unrelated root cause)