Skip to the content.

MCPg scaling characteristics

How MCPg behaves under load, the levers you have to tune it, and the bottlenecks to plan around.


Execution model


Connection pool sizing

The pool is bounded by MCPG_POOL_MIN_SIZE / MCPG_POOL_MAX_SIZE (defaults 1 / 5).

Cursor pool overhead

Each open server-side cursor holds a dedicated connection outside the main pool — for the cursor’s lifetime. Long-lived cursors don’t starve the main pool but do count against the PostgreSQL server’s max_connections. The 5-minute idle TTL caps drift; list_cursors() makes the population visible.

Replica pool overhead

Each entry in MCPG_REPLICA_URLS gets its own pool sized identically to the primary (MCPG_POOL_MIN_SIZE / MCPG_POOL_MAX_SIZE). Plan for (1 + N_replicas) × MCPG_POOL_MAX_SIZE peak connections across the database fleet.


Read-replica routing

When MCPG_REPLICA_URLS is non-empty, every force_readonly=True query is round-robin-routed to a healthy replica. Failures fall back to the primary once and mark the replica degraded for 30 s.

Capacity guidance:

Monitor list_replicas() for the degraded flag, last error, and seconds-until-retry per replica.


Result-size bounds


Subprocess workloads

dump_database / restore_database / copy_table_between_databases spawn pg_dump / psql / pg_restore with:

Subprocess CPU runs outside the event loop, so it doesn’t block other tool calls — but the captured output sits in memory until the subprocess completes. Plan accordingly for big dumps.


Rate limiting

MCPG_RATE_LIMIT_ENABLED=true activates a token-bucket per-tool limiter:

Rate-limited calls return an error immediately rather than queueing.


Measured baseline

A baseline from benchmarks/bench.py — 2000 run_select calls of SELECT 1, concurrency 16, against a loopback PostgreSQL 16:

Metric Value
Throughput ~2,200 req/s
Latency p50 ~6.8 ms
Latency p95 ~11 ms

This measures the MCPg query path (safety validation + pool + round-trip) against a trivial query on the same host — not the MCP transport, not network latency, not query complexity. Real workloads vary; re-run against your own environment:

uv run python benchmarks/bench.py --requests 2000 --concurrency 16 \
    --database-url postgresql://...

Observability for capacity planning

The HTTP transport exposes Prometheus metrics at GET /metrics:

For stdio deployments, get_metrics_exposition() returns the same Prometheus payload as a string the agent can hand to a scrape target.

Together with check_database_health and audit_database, these cover the three axes you need:


Bottlenecks & guidance


Horizontal scale-out shape

For deployments needing more capacity than a single MCPg process can provide:

  1. HTTP transport in front of a load balancer. MCPg is stateless aside from the audit logger and the open-cursor table — a sticky session is needed only if your workflow depends on a single client holding a cursor across calls (otherwise round-robin works).
  2. Replica pool for read fan-out. MCPG_REPLICA_URLS is the first lever for read-heavy workloads.
  3. Tenant-per-database isolation. When tenants run in separate databases, run one MCPg instance per tenant with a tenant-specific MCPG_DATABASE_URL. When tenants share a database, the single-process SET LOCAL ROLE workflow (MCPG_DEFAULT_ROLE / OIDC role claim / X-MCPG-Role) is typically a better fit.

See also