Database guide · 2026-06-21 · MCP Server Database Integration
Five Database Backends for MCP Servers: MongoDB, Supabase, Neon, DynamoDB, and Turso
Every MCP server that does something useful reads from or writes to a database. Choosing the wrong backend — or connecting the right one incorrectly — produces silent failure modes that your uptime check misses: tools that return HTTP 200 while queries time out internally, connections that appear healthy while writes are rejected, and auth tokens that expire while the process stays alive. This guide synthesizes the five most common database backends for TypeScript MCP servers — MongoDB, Supabase, Neon, DynamoDB, and Turso — covering the critical driver decisions for each, the security patterns that prevent injection at the tool layer, the silent failure modes that standard HTTP probes cannot catch, and the /health endpoint implementation that surfaces each failure mode to AliveMCP before callers hit it.
The five backends at a glance
| Backend | Best fit | Key driver decision | Silent failure mode | Health signal |
|---|---|---|---|---|
| MongoDB | Flexible documents, aggregation, unstructured data | Native driver singleton, not Mongoose | Connection pool exhaustion — tools timeout, probe stays green | /health/mongodb: ping + pool stats |
| Supabase | Postgres + auth + realtime in a managed platform | service_role for admin tools, per-request userClient(jwt) for RLS |
Free-tier project pause — Supabase returns 503, MCP process returns 200 | /health/supabase: project reachability + RLS canary |
| Neon | Serverless Postgres, branch-per-PR, no idle connections | HTTP driver for simple queries, TCP pool for transactions | Compute credit exhaustion — queries fail, /health HTTP check succeeds |
/health/neon: classify warm/cold-start/slow by response_ms |
| DynamoDB | AWS-native, high throughput, key-value and single-table patterns | SDK v3 DynamoDBDocumentClient with IAM credential chain |
Read throttling — SDK retries 3× with backoff, tool fails after 3s while DescribeTable returns ACTIVE |
/health/dynamodb: DescribeTableCommand + put/get canary |
| Turso | Edge-deployed SQLite, Cloudflare Workers, globally distributed reads | libsql:// for Turso cloud, file: for local SQLite |
Auth token expiry — 401 from libSQL while MCP process alive and returning 200 | /health/turso: classify ok/auth_expired/error from SELECT 1 |
MongoDB: native driver, injection prevention, and pool exhaustion
MongoDB is the natural choice when your MCP server needs to query flexible, schema-less documents, embed unstructured data, or run aggregation pipelines across large collections. The critical driver decision is to use the native mongodb npm package rather than Mongoose. Mongoose adds schema validation, middleware hooks, and hydration — none of which benefit an MCP server's tool layer, and all of which add latency and memory overhead at runtime. The correct pattern is a singleton MongoClient initialized once at startup with ServerApiVersion.v1 strict mode, which prevents accidental use of deprecated MongoDB 4.x behaviors.
Security at the tool layer requires explicit Zod field allow-lists on every CRUD tool. Never pass a caller-controlled object directly as a MongoDB filter — this creates a NoSQL injection path where an agent can pass { "$where": "..." } or { "$gt": "" } to bypass intended query constraints. The correct pattern extracts only the fields your tool intends to filter on — { name: input.name, status: input.status } — and constructs the filter document explicitly. For aggregation pipeline tools, maintain a stage deny-list that blocks $out and $merge for non-admin callers: both stages write to collections, so an agent with access to a read-scoped tool could use an aggregation to bypass write restrictions.
Exposing MongoDB collections as MCP resources requires care with ObjectId serialization. ObjectId.toJSON() returns an object with $oid key (MongoDB Extended JSON format), which is not a valid string URI component. Always use ObjectId.toHexString() to get the 24-character hex representation, and adopt a URI scheme like mongodb://{collection}/{objectId} for resource addresses.
The silent failure mode that kills MongoDB-backed MCP servers: connection pool exhaustion. When all connections in the pool are in use and waitingCount is non-zero, new tool calls queue for a connection. The MCP initialize handshake and tools/list response never touch the pool, so the protocol probe returns green. Eventually the waitTimeoutMS fires and the queued tool call gets a timeout error — but only after the timeout period, which is often several seconds. From the external probe's perspective, the server is healthy. From the tool caller's perspective, every tool call is hanging.
The fix: expose pool stats in /health/mongodb. The native driver exposes client.s.topology.s.pool internals, or you can use the connectionPoolMonitor event emitter to track pending, available, and total counts. Return HTTP 503 when pending > 0 — a pending count above zero means callers are queuing, which is always a degraded state. AliveMCP polling /health/mongodb will fire an alert before users experience the timeout cascade.
Two additional MongoDB failure modes warrant health monitoring: replica failover and cursor timeout. During a primary election (10–30 seconds), writes fail with NotPrimaryError while reads may still succeed depending on your readPreference. Track NotPrimaryError count in your health state and alert when it exceeds zero. Cursor timeouts — MongoDB server-side cursor expiry after the default 10-minute cursorTimeoutMS — appear as "cursor not found" errors on long-running tool calls. If your tools paginate large collections, either use a cursor.toArray() with a reasonable limit or implement resumable pagination with stored cursor positions.
Supabase: service role vs anon key, RLS enforcement, and the project pause trap
Supabase is attractive for MCP servers because it combines Postgres, auth, realtime subscriptions, and file storage under a single client SDK. The first decision is which key to use. The anon key is subject to Row Level Security policies — it can only read or write rows that RLS allows for the current user context. The service_role key bypasses RLS entirely and has unrestricted table access. MCP servers should use service_role only for admin tools that genuinely need unrestricted access, and should use a per-request userClient(jwt) pattern for tools that operate in a user's context. The userClient creates a new Supabase client with the user's JWT token, which Supabase's PostgREST layer applies to set the RLS context for every query the client makes.
The Supabase SDK returns { data, error } from every query rather than throwing. This creates a subtle injection point: tool handlers that check if (!data) treat data: null, error: null (no rows matched) the same as data: [], error: null (empty result), and both differently from data: null, error: {...} (query failed). Wrap every Supabase call in an assertNoError() helper that throws when error !== null, and return isError: true from the tool handler when that throw is caught. This keeps the tool response contract consistent: either data is present and valid, or the handler returns an error.
For tools that write to Supabase Storage, return file content as base64-encoded binary in the MCP content array with type: "blob". Avoid returning raw binary buffers — MCP content must be serializable to JSON for transport across the stdio or SSE channel.
The Supabase failure mode that no standard probe catches: free-tier project pause. Supabase pauses free-tier projects after 7 days of inactivity. When paused, Supabase returns HTTP 503 from the API. Your MCP process is still alive, still responding to initialize, still returning tools/list — but every tool call that touches Supabase will fail. A plain HTTP health check against your MCP server's root URL returns 200. The probe needs to actually call a Supabase endpoint to catch the pause state.
Implement /health/supabase as a lightweight Supabase API call — a SELECT 1 via the service-role client works. When this returns a 503 with a "project is paused" message, return HTTP 503 from your health endpoint. AliveMCP polling the health endpoint will alert immediately instead of waiting for an agent to complain that no tool calls are succeeding.
Supabase Realtime's postgres_changes subscription integrates naturally with MCP resource subscriptions and MCP notifications: when a postgres_changes event fires, call server.sendResourceListChanged() or server.sendResourceUpdated(uri) to push the change to all subscribed MCP clients. However, Supabase Realtime connections on free tier have concurrency limits and can disconnect silently. Track lastRealtimeMessageAt in your health state and alert if no message has been received for more than 3× your expected event frequency — a silent realtime disconnection makes all resource subscriptions stale.
Neon: HTTP vs TCP driver, branch-per-PR, and compute credit exhaustion
Neon is a serverless Postgres provider that scales to zero between requests. The architectural decision that defines your MCP server's behavior is which Neon client to use: the HTTP driver (neon() tagged template function) or the TCP connection pool (the pg package with Neon's WebSocket tunnel).
The HTTP driver sends each query as an HTTP POST to Neon's API. It requires no connection pool, survives Neon compute scale-to-zero transparently (the next query simply wakes the compute up), and works in environments that prohibit long-lived TCP connections — including Cloudflare Workers and some Lambda configurations. The tradeoff: no transactions, no prepared statements, and a ~100ms cold-start latency on the first query after scale-to-zero. For MCP servers where each tool call is independent and stateless, the HTTP driver is the correct default.
The TCP pool (using pg with Neon's @neondatabase/serverless WebSocket adapter) supports transactions and prepared statements, but has a ~500ms cold-start penalty when the pool needs to establish a WebSocket connection after Neon compute wakes up. Use it only when tools genuinely need multi-statement transactions — typically write tools that update multiple tables atomically.
Neon's branch-per-PR workflow is the most underused feature in production MCP server development. neonctl branches create --parent main --name pr-123 creates a copy-on-write clone of your production database in under a second. Pull requests that include schema migrations can be tested against a real copy of production data without risk of affecting the main branch. After the PR merges, neonctl branches delete pr-123 reclaims the compute. This workflow is particularly valuable for MCP servers that expose schema-dependent tools — a migration that adds a new column will cause tools/list to return a tool with a different schema, which is a breaking change for any agent that cached the tool manifest.
Implement a keep-warm strategy if your MCP server's tool latency SLA is under 200ms. A setInterval(() => db.execute('SELECT 1'), 240000) (every 4 minutes) prevents Neon compute from scaling to zero between tool calls. The tradeoff is a constant minimum compute cost. For MCP servers with sporadic traffic, accepting the cold-start latency and classifying it explicitly in the health endpoint is more cost-effective.
The Neon failure mode that health checks miss: compute credit exhaustion on the free tier. When Neon's free compute hours are exhausted for the month, queries fail with a "compute endpoint suspended" error. Your MCP process is alive. A plain TCP check succeeds because the Neon WebSocket proxy is still reachable. Only a query that touches the suspended compute returns an error.
Implement /health/neon with a timed SELECT 1 that measures response time and classifies three states: warm (response under 50ms — compute was already running), cold_start (response 50ms–500ms — compute woke up during the query), and slow (response over 500ms — credit exhaustion or network issue). Return HTTP 503 for slow state to give AliveMCP a reliable signal. This classification also lets you tune your keep-warm strategy: if the health check consistently shows cold_start, the 4-minute keep-warm interval is too long.
DynamoDB: single-table design, IAM credential chain, and throttling invisibility
DynamoDB is the right backend when your MCP server runs inside the AWS ecosystem, needs key-value performance at scale, or must avoid the connection pool management that relational databases require. DynamoDB uses an HTTP API rather than persistent connections, making it naturally suited to serverless MCP server deployments on Lambda.
Use the AWS SDK v3 DynamoDBDocumentClient, not the low-level DynamoDBClient. The DocumentClient handles JavaScript type marshaling automatically — it converts JavaScript objects to DynamoDB's typed format ({ S: "string" }, { N: "number" }) and back without manual marshaling code in every tool handler. Initialize it once at module load time rather than per-request; the SDK manages HTTP connection reuse internally.
Credentials should always come from the IAM credential provider chain (fromNodeProviderChain()) rather than hardcoded access keys. On EC2 and Lambda, the chain resolves to the instance or function's IAM role automatically. In local development, it resolves to the AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY environment variables or the ~/.aws/credentials file. Never store AWS credentials as environment variables on production MCP servers — IAM roles are more secure and require no rotation management.
Single-table design — where all entity types share one DynamoDB table — is the recommended pattern for MCP server backends with multiple entity types. Use composite keys where PK carries the entity type and ID (USER#123, ORDER#456) and SK carries relationship metadata (METADATA, ORDER#001 for a user's orders). This enables a single QueryCommand with KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)' to retrieve all of a user's orders without a scan.
Three DynamoDB query patterns require special handling in tool code. First, GetCommand returns { Item: undefined } when the key does not exist — not an error, not an empty array. Tool handlers must explicitly check for undefined and return an appropriate "not found" tool result rather than trying to access properties on undefined. Second, QueryCommand results may be paginated via LastEvaluatedKey — if LastEvaluatedKey is present, there are more pages. Tools that need complete results must loop with ExclusiveStartKey until LastEvaluatedKey is absent. Third, QueryCommand expressions cannot use DynamoDB reserved words as attribute names without quoting via ExpressionAttributeNames — status, name, and type are all reserved. Use #s as the expression placeholder and { '#s': 'status' } in the names map.
Optimistic locking in UpdateCommand uses a ConditionExpression to check that the item version matches what the tool read before writing. When the condition fails because another process updated the item first, DynamoDB throws ConditionalCheckFailedException. This is a 4xx-class error (caller conflict) rather than a 5xx-class error (server failure) — tool handlers must catch it specifically and return isError: true with a "conflict" message rather than letting it propagate as an unhandled exception.
The DynamoDB failure mode that is invisible to health checks: read throttling. When a DynamoDB table's provisioned read capacity is exhausted, read operations are throttled. The AWS SDK v3 automatically retries throttled requests up to three times with exponential backoff. During the retry window — up to 3 seconds by default — the tool call is hanging. Meanwhile, DescribeTableCommand returns the table as ACTIVE because table status reflects provisioning, not throughput. A health check that calls DescribeTable will return green while every tool call that reads user data is queuing for retries.
The fix: include a canary put/get cycle in /health/dynamodb. Write a sentinel item (PK: "HEALTH_CHECK", SK: "PROBE") and immediately read it back. Measure the round-trip time. If the read takes longer than 500ms, throttling is likely. If the GetCommand fails with ProvisionedThroughputExceededException even after SDK retries, return HTTP 503. This health check exercises the actual read capacity path that tool calls use.
Turso: edge SQLite, embedded replicas, and auth token expiry
Turso brings SQLite to MCP servers that need globally distributed low-latency reads, edge deployment on Cloudflare Workers, or a zero-infrastructure local development experience. The @libsql/client package supports two URL schemes that cover the full deployment spectrum: libsql://your-db.turso.io connects to a Turso-hosted database over HTTPS, and file:./local.db connects to a local SQLite file for development and testing. Using an environment variable to switch between them (DATABASE_URL) gives you production parity in local development with zero configuration changes to tool code.
SQL injection is not a realistic attack surface when using Turso's execute() with positional arguments — client.execute({ sql: 'SELECT * FROM users WHERE id = ?', args: [userId] }) — because positional arguments are never interpolated into the SQL string by the driver. However, never construct SQL strings with template literals or string concatenation from caller-controlled input, even when using Turso. The habit of using execute({ sql, args }) consistently is the correct default.
Turso's batch() method sends multiple statements in a single HTTP POST and executes them atomically. It accepts a mode argument ('write' or 'read') that routes the batch to the appropriate replica. Write batches must use 'write' mode to ensure they land on the primary. Read batches with 'read' mode route to the nearest read replica, reducing latency for geographically distributed MCP server deployments. Use batch() for any tool that updates multiple tables — individual sequential execute() calls leave a window where a partial update is visible to concurrent readers.
Embedded replica mode is Turso's most powerful feature for latency-sensitive MCP servers. When you create the client with a syncUrl (the remote Turso database URL), @libsql/client maintains a local SQLite file that is periodically synced with the remote primary. Reads run against the local file at sub-millisecond latency with no network round-trip. Call await client.sync() at tool handler entry to pull the latest changes before reading, or run sync on a background interval if you can tolerate replication lag. The tradeoff: writes still go to the remote primary, and there is a window between a write completing and the sync propagating to the local replica during which a read-after-write will return stale data.
On Cloudflare Workers, the libsql:// URL scheme is the only option — Workers do not support Node.js's net.Socket, so local SQLite files are not available at runtime. Use Workers KV for rate limiting and per-request state that does not need durability, and Turso for durable data that needs to survive across requests and Workers instances.
The Turso failure mode that standard probes miss: auth token expiry. Turso databases are secured with JWT tokens that have an expiration time. When the token expires, every execute() call returns a 401 error from the Turso API. Your MCP process is alive. A TCP health check against the MCP server's HTTP port returns 200. A health check that pings the MCP endpoint directly returns 200 because the initialize handler never calls Turso. Only a query that touches Turso will surface the 401.
Implement /health/turso with a SELECT 1 executed via the libSQL client. Catch the error response and classify it: if the HTTP status is 401 or the error message contains "unauthorized" or "expired", return { status: "auth_expired" } with HTTP 503 so AliveMCP alerts immediately. Free-tier databases on Turso are also automatically deleted after 10 days of inactivity — implement a keep-alive SELECT 1 on the same 4-minute interval as the Neon keep-warm pattern, or structure your health check to also serve as the activity signal.
Choosing the right database for your MCP server
The decision is not primarily about which database is fastest or most scalable — it is about which backend fits the deployment environment, the data model, and the team's existing expertise.
| Criterion | MongoDB | Supabase | Neon | DynamoDB | Turso |
|---|---|---|---|---|---|
| Data model | Documents, flexible schema | Relational, multi-tenant | Relational, serverless | Key-value, single-table | Relational, SQLite syntax |
| Auth/RLS built-in | No | Yes (Postgres RLS + Supabase Auth) | No (Postgres RLS via SET app.user) |
No (IAM resource policies) | No |
| Serverless / scale-to-zero | Atlas Serverless only | Free-tier pauses (problem) | Native, first-class | Native, HTTP API | Yes (HTTP API) |
| Edge / Workers compatible | No (TCP driver) | No (TCP driver) | Partial (HTTP driver only) | Partial (HTTP API, no KMS) | Yes |
| Existing AWS stack | No advantage | No advantage | No advantage | Strong advantage (IAM, VPC, Lambda) | No advantage |
| Transactions | Multi-doc (4.0+) | Yes (Postgres) | Yes (TCP driver) | TransactWrite (25 items) | Yes (batch mode) |
If you are building on AWS Lambda or ECS and your team already manages IAM policies, DynamoDB requires the least operational surface area — no connection pool to size, no TLS certificate to rotate, no database server to patch. If you need Postgres semantics with built-in auth and a dashboard your team can use without SQL knowledge, Supabase is the correct choice for early-stage MCP servers, with the caveat that you must move off the free tier before production traffic arrives. Neon is the right Postgres choice when you need branch-per-PR database workflows and zero idle connection cost — the serverless model matches MCP servers that have bursty, infrequent traffic patterns. MongoDB fits MCP servers where the tool layer needs to query and aggregate across documents with heterogeneous structure. Turso is the only choice for Cloudflare Workers deployments and any MCP server where sub-millisecond local read latency is a requirement.
A unified health monitoring strategy across all five backends
The pattern that applies across all five databases is the same: the MCP protocol probe (the one AliveMCP runs) verifies that the server is alive and the tool list is reachable, but it cannot verify that the database behind the tools is working correctly. The solution in every case is a dedicated /health/{backend} endpoint that actually executes a database operation and reports the result.
The health endpoint should do three things: verify that the connection can be established (not just that the client object exists), execute a canary query that exercises the actual data path, and report a classified status that distinguishes between "database unreachable," "database reachable but degraded," and "database healthy." For most backends, "degraded" is the most important state — the one where tools return 200 but callers get wrong answers or slow responses.
Configure AliveMCP to monitor both the MCP protocol endpoint and the custom health URL. The protocol probe catches process death, TLS expiry, and network partitions — failure classes that the database health endpoint cannot report because the server is already unreachable. The custom health URL catches database-layer failures — pool exhaustion, project pause, compute exhaustion, throttling, and auth expiry — that the protocol probe cannot detect because the MCP handshake never touches the database. Together, they cover the full failure surface.
The five silent failure modes documented in this guide — MongoDB pool exhaustion, Supabase project pause, Neon compute credit exhaustion, DynamoDB throttling invisibility, and Turso auth token expiry — share a common characteristic: they produce a false positive at the protocol layer. The MCP initialize message succeeds, tools/list returns the expected manifest, and HTTP checks return 200. Only tool calls that touch the database fail, and only after the caller waits for a timeout or retry window that makes the failure appear slow rather than broken. Health-endpoint monitoring that exercises the database layer directly is the only way to catch these states before callers discover them.
FAQ
- Can I use multiple databases in the same MCP server?
- Yes. MCP tools are independent handlers — one tool can read from MongoDB while another reads from DynamoDB. Initialize each client as a singleton at module load. The main risk is health endpoint complexity: if any backend is unhealthy, you need to decide whether the server is degraded or fully down, and which tool subset is affected. Structure your
/healthendpoint to report per-backend status ({ mongodb: "healthy", dynamodb: "throttled" }) so AliveMCP can surface which tools are affected rather than just "health check failed." - How do I prevent database credentials from leaking through MCP tool responses?
- Never return raw database errors to MCP tool callers. Database drivers include connection strings, table names, and internal identifiers in error messages. Catch all database errors at the tool handler boundary, log the full error internally, and return a sanitized
isError: trueresult with a generic "internal error" message to the caller. This also prevents information leakage through agent logs when the agent's conversation is stored or inspected. - Should I use an ORM like Drizzle or Prisma with these databases?
- ORMs add type safety and migration tooling but introduce an abstraction layer that can make debugging harder when queries behave unexpectedly. For MCP servers, the pragmatic approach is to use ORMs in development (Drizzle's type-safe query builder, Prisma's schema-first migrations) but verify that the generated SQL or query plan matches your intent. Mongoose specifically should be avoided with MongoDB in MCP server contexts — the overhead is not justified at the tool layer. Drizzle works well with Neon and Turso (both support standard SQL syntax). Prisma works with Supabase but requires the connection pooler URL rather than the direct database URL.
- What is the right connection pool size for a DynamoDB-backed MCP server?
- DynamoDB uses HTTP rather than persistent connections, so there is no TCP connection pool to size. The AWS SDK v3 manages HTTP connection reuse internally via Node.js's
http.Agentkeep-alive. The SDK defaults to 50 maximum sockets per endpoint. This is sufficient for most MCP server traffic patterns. If you are running many concurrent tool calls, increasemaxSocketsin the SDK'srequestHandlerconfiguration. Monitor theETIMEDOUTerror rate from the SDK — socket exhaustion appears as connection timeout errors rather than DynamoDB API errors. - How do I handle database schema migrations without downtime for a running MCP server?
- The safest migration pattern for MCP servers is expand-contract: first deploy a migration that adds the new column or table (expand), then deploy the tool code that uses the new schema, then remove the old column or table in a subsequent migration (contract). During the expand phase, tools work with both the old and new schema simultaneously. This avoids the window where a migration has run but the new tool code has not yet deployed, which is when schema-dependent tools return unexpected errors. Neon's branch-per-PR workflow lets you validate the new tool code against the migrated schema before merging either change to production.
Monitor your database-backed MCP server with AliveMCP
AliveMCP runs both the MCP protocol probe (verifying initialize and tools/list) and your custom health URL (verifying the database layer) on a 60-second cycle. When MongoDB pool exhaustion silences your tools, when Supabase pauses your free-tier project, or when a Turso auth token expires at 3am, AliveMCP fires the alert before your agent workflows discover the failure. Add your MCP server to the dashboard and wire your /health/{backend} endpoint — the combination covers the full failure surface that either probe misses alone.