Guide · MCP Database Integration
MCP Server Turso — edge-deployed SQLite for globally distributed MCP servers
Turso is a distributed SQLite database built on libSQL, an open-source fork of SQLite with HTTP API support. For MCP servers deployed to Cloudflare Workers, Deno Deploy, or other edge runtimes, Turso provides a globally replicated SQLite instance reachable via HTTP — no TCP connection required, no connection pool to manage, and read latency under 10ms from the nearest edge replica. This guide covers integrating Turso's @libsql/client into a TypeScript MCP server, the HTTP client vs embedded replica tradeoffs, batch operations for efficient tool handlers, and health endpoints that detect auth token expiry and replication lag.
TL;DR
Create a Turso client with createClient({ url, authToken }) — the HTTP client (libsql:// URL) requires no persistent connection, making each execute() call an independent HTTP POST. Use client.batch() for multi-statement atomic operations rather than issuing statements one at a time. Auth token expiry is the most common silent failure mode: Turso auth tokens have configurable expiry, and when they expire all queries return 401 while your MCP server process stays alive. Wire a /health/turso endpoint that specifically catches 401 responses and surfaces them as auth_expired status so AliveMCP can alert you before tool calls start failing.
Turso client setup
The @libsql/client package supports two URL schemes that determine whether you use the cloud HTTP API or a local SQLite file. Production MCP servers use the libsql:// scheme; development uses file:.
import { createClient, Client } from '@libsql/client';
// Production: Turso cloud database (HTTP API, globally replicated)
// URL format: libsql://{org-name}-{db-name}.turso.io
const turso: Client = createClient({
url: process.env.TURSO_DATABASE_URL!, // libsql://org-mydb.turso.io
authToken: process.env.TURSO_AUTH_TOKEN! // JWT token from Turso dashboard
});
// Development: local SQLite file (no auth token needed)
const tursoLocal: Client = createClient({
url: 'file:./dev.db'
// No authToken for local file — auth is cloud-only
});
// Export whichever is appropriate for the environment
export const db: Client = process.env.NODE_ENV === 'production' ? turso : tursoLocal;
// Handle auth token expiry gracefully
// Turso auth tokens are JWTs with an expiry date (configurable: 1 day to never)
// When expired, all execute() calls throw with a 401 UNAUTHORIZED error
// Detect and surface this specifically — don't swallow it as a generic error
export function isTursoAuthError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
return err.message.includes('UNAUTHORIZED') ||
err.message.includes('401') ||
err.message.includes('token') && err.message.toLowerCase().includes('expir');
}
| URL scheme | Connection type | Auth required | Use case |
|---|---|---|---|
libsql://org-db.turso.io |
HTTP POST per query | Yes (authToken) |
Production, edge runtimes, Cloudflare Workers |
file:./data.db |
Local SQLite file | No | Local development, testing |
file:./replica.db + syncUrl |
Local file + HTTP sync | Yes (authToken) |
High-read MCP servers, sub-ms reads with eventual sync |
ws://localhost:8080 |
sqld WebSocket | Optional | Self-hosted libSQL server (sqld) |
Execute and batch operations
Every Turso query is parameterized via the args array — there's no string interpolation API, which eliminates SQL injection by construction. Use client.batch() for atomic multi-statement operations; it sends all statements in a single HTTP request.
import { db } from './turso.js';
// Single query — args are positional parameters
const userId = 'user-123';
const result = await db.execute({
sql: 'SELECT id, email, plan, created_at FROM users WHERE id = ? AND active = ?',
args: [userId, 1]
});
// Result shape: { rows, columns, rowsAffected, lastInsertRowid }
console.log(result.rows); // Array of Row objects
console.log(result.columns); // ['id', 'email', 'plan', 'created_at']
// Access row values by column name (Row implements ArrayLike + object access)
for (const row of result.rows) {
const email = row[1]; // by index
const plan = row['plan']; // by column name (after column lookup)
}
// Batch — atomic, all-or-none transaction
// 'write' mode: all statements go to the primary (consistent reads)
// 'read' mode: can use replica (may return stale data)
const batchResults = await db.batch([
{
sql: 'UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?',
args: [100, 'acc-from', 100]
},
{
sql: 'UPDATE accounts SET balance = balance + ? WHERE id = ?',
args: [100, 'acc-to']
},
{
sql: 'INSERT INTO transfers (from_id, to_id, amount, created_at) VALUES (?, ?, ?, ?)',
args: ['acc-from', 'acc-to', 100, new Date().toISOString()]
}
], 'write');
// batchResults is an array matching the input statements
console.log(batchResults[0].rowsAffected); // 1 if update succeeded (balance was sufficient)
// JSON functions — query JSON columns with SQLite's json_extract
const toolsResult = await db.execute({
sql: `
SELECT id, name, json_extract(schema_json, '$.description') as description
FROM mcp_tools
WHERE json_extract(schema_json, '$.enabled') = 1
ORDER BY name
LIMIT ?
`,
args: [50]
});
MCP tools with Turso
Turso's libSQL supports SQLite's full JSON extension (json_extract, json_each, json_object), making it practical to store MCP tool schemas and configuration as JSON columns in SQLite — a common pattern for MCP registry servers.
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { db, isTursoAuthError } from './turso.js';
// Wrap all Turso calls to catch auth expiry
async function tursoExec(sql: string, args: unknown[] = []) {
try {
return await db.execute({ sql, args });
} catch (e) {
if (isTursoAuthError(e)) {
throw new McpError(
ErrorCode.InternalError,
'Database auth token has expired — please contact the server administrator'
);
}
throw e;
}
}
// ---- query_tools ----
server.tool(
'query_tools',
{
category: z.string().optional(),
enabled: z.boolean().default(true),
limit: z.number().int().min(1).max(100).default(25),
offset: z.number().int().min(0).default(0)
},
async ({ category, enabled, limit, offset }) => {
let sql = `
SELECT id, name, category,
json_extract(schema_json, '$.description') as description,
json_extract(schema_json, '$.version') as version,
enabled, created_at
FROM mcp_tools
WHERE enabled = ?
`;
const args: unknown[] = [enabled ? 1 : 0];
if (category) {
sql += ' AND category = ?';
args.push(category);
}
sql += ' ORDER BY name LIMIT ? OFFSET ?';
args.push(limit, offset);
const result = await tursoExec(sql, args);
return { content: [{ type: 'text', text: JSON.stringify(result.rows, null, 2) }] };
}
);
// ---- register_tool ----
server.tool(
'register_tool',
{
name: z.string().min(1).max(100),
category: z.string().min(1),
schema: z.record(z.unknown()),
enabled: z.boolean().default(true)
},
async ({ name, category, schema, enabled }) => {
const result = await tursoExec(
`INSERT INTO mcp_tools (name, category, schema_json, enabled, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
category = excluded.category,
schema_json = excluded.schema_json,
enabled = excluded.enabled,
updated_at = excluded.created_at`,
[name, category, JSON.stringify(schema), enabled ? 1 : 0, new Date().toISOString()]
);
return {
content: [{
type: 'text',
text: JSON.stringify({
inserted: result.rowsAffected > 0,
lastInsertRowid: result.lastInsertRowid?.toString()
})
}]
};
}
);
// ---- delete_tool ----
server.tool(
'delete_tool',
{ id: z.number().int().positive() },
async ({ id }) => {
const result = await tursoExec('DELETE FROM mcp_tools WHERE id = ?', [id]);
if (result.rowsAffected === 0) {
throw new McpError(ErrorCode.InvalidParams, `Tool with id=${id} not found`);
}
return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, id }) }] };
}
);
Embedded replica mode
For MCP servers with high read throughput, embedded replica mode syncs a local SQLite copy from Turso cloud. Reads are sub-millisecond (local disk), writes go to Turso cloud and propagate back to the local replica asynchronously.
import { createClient, Client } from '@libsql/client';
// Embedded replica: local file + sync from Turso cloud
const embeddedClient: Client = createClient({
url: 'file:./replica.db', // local SQLite file
syncUrl: process.env.TURSO_DATABASE_URL!, // sync target (libsql:// URL)
authToken: process.env.TURSO_AUTH_TOKEN!,
syncInterval: 60 // auto-sync every 60 seconds (optional)
});
// Sync on startup — blocks until initial sync is complete
await embeddedClient.sync();
console.log('Embedded replica synced');
// Reads are instant — served from local SQLite file
const result = await embeddedClient.execute({
sql: 'SELECT COUNT(*) as count FROM mcp_tools WHERE enabled = 1',
args: []
});
// Writes go to Turso cloud, then propagate to the local replica
// There's a replication lag of 1–5 seconds between write and local visibility
await embeddedClient.execute({
sql: 'INSERT INTO mcp_tools (name, category, schema_json, enabled, created_at) VALUES (?, ?, ?, ?, ?)',
args: ['new-tool', 'utility', '{}', 1, new Date().toISOString()]
});
// WARNING: immediately reading back this row from the embedded replica
// may return nothing for 1–5 seconds. For tools where write-then-read consistency
// matters, use the HTTP client (libsql:// URL) for the read, not the replica.
// Periodic sync if not using syncInterval
setInterval(async () => {
try {
await embeddedClient.sync();
} catch (e) {
console.error('Replica sync failed:', (e as Error).message);
}
}, 30_000);
| Mode | Read latency | Write latency | Consistency | Disk required |
|---|---|---|---|---|
| HTTP client (libsql://) | ~5–50ms (network round-trip) | ~5–50ms | Strong (always primary) | No |
| Embedded replica | <1ms (local file) | ~5–50ms (write goes to primary) | Eventual (1–5s replication lag) | Yes |
Edge deployment with Cloudflare Workers
Cloudflare Workers run in a JavaScript runtime that doesn't support local file I/O or persistent processes. Use the HTTP Turso client only — no embedded replica mode, no file:// URLs.
// wrangler.toml — configure Turso secrets for Workers
// [vars]
// TURSO_DATABASE_URL = "libsql://org-db.turso.io"
//
// Add secrets via: wrangler secret put TURSO_AUTH_TOKEN
// src/index.ts — Cloudflare Worker MCP server
import { createClient } from '@libsql/client/http'; // HTTP-only build for Workers
import { McpAgent } from 'agents/mcp'; // MCP Workers SDK
interface Env {
TURSO_DATABASE_URL: string;
TURSO_AUTH_TOKEN: string;
RATE_LIMIT: KVNamespace; // Workers KV for rate limiting
}
export class MyMcpServer extends McpAgent<Env> {
server = new McpServer({ name: 'my-mcp-server', version: '1.0.0' });
async init() {
const db = createClient({
url: this.env.TURSO_DATABASE_URL,
authToken: this.env.TURSO_AUTH_TOKEN
});
this.server.tool('query_tools', { limit: z.number().default(25) }, async ({ limit }) => {
// Rate limit check via Workers KV
const clientIp = this.ctx.request?.headers.get('CF-Connecting-IP') ?? 'unknown';
const rateLimitKey = `rl:${clientIp}`;
const existing = await this.env.RATE_LIMIT.get(rateLimitKey);
if (existing && parseInt(existing) > 100) {
throw new McpError(ErrorCode.InvalidRequest, 'Rate limit exceeded');
}
await this.env.RATE_LIMIT.put(
rateLimitKey,
String((parseInt(existing ?? '0')) + 1),
{ expirationTtl: 60 }
);
const result = await db.execute({ sql: 'SELECT * FROM mcp_tools LIMIT ?', args: [limit] });
return { content: [{ type: 'text', text: JSON.stringify(result.rows) }] };
});
}
}
Health endpoint: /health/turso
The Turso health endpoint must distinguish three states: healthy, auth expired (401), and network/database error. AliveMCP should alert on both the error state and the auth_expired state — both mean tools are returning errors to callers.
import express from 'express';
import { db, isTursoAuthError } from './turso.js';
const app = express();
app.get('/health/turso', async (_req, res) => {
const start = Date.now();
// 1. Basic connectivity — SELECT 1
try {
await db.execute({ sql: 'SELECT 1', args: [] });
const responseMs = Date.now() - start;
// 2. Check replication lag (embedded replica mode only)
let replicationLagSeconds: number | null = null;
try {
const lagResult = await db.execute({
sql: `SELECT unixepoch('now') - unixepoch(MAX(synced_at)) as lag_seconds
FROM _turso_sync_log
WHERE synced_at IS NOT NULL`,
args: []
});
replicationLagSeconds = lagResult.rows[0]?.['lag_seconds'] as number ?? null;
} catch {
// _turso_sync_log doesn't exist in HTTP mode (not embedded replica) — ignore
}
const isLagging = replicationLagSeconds !== null && replicationLagSeconds > 30;
res.status(isLagging ? 503 : 200).json({
status: isLagging ? 'degraded' : 'ok',
response_ms: responseMs,
replication_lag_seconds: replicationLagSeconds,
replication_status: replicationLagSeconds === null
? 'http-mode'
: replicationLagSeconds <= 5
? 'current'
: replicationLagSeconds <= 30
? 'slightly-behind'
: 'lagging'
});
} catch (e) {
const elapsed = Date.now() - start;
if (isTursoAuthError(e)) {
// Auth token expired — all tool calls are failing
res.status(503).json({
status: 'auth_expired',
error: 'Turso auth token has expired — renew TURSO_AUTH_TOKEN',
elapsed_ms: elapsed
});
} else {
res.status(503).json({
status: 'error',
error: (e as Error).message,
elapsed_ms: elapsed
});
}
}
});
Silent failure modes
| Failure mode | Symptom | Detected by naive HTTP check? | How to detect |
|---|---|---|---|
| Auth token expiry | All Turso queries return 401. MCP server process is alive. execute() throws. Tool calls fail with McpError. |
No — server process returns 200 | /health/turso catches 401 and returns status: auth_expired |
| Replication lag (embedded replica) | Writes visible at Turso cloud primary but not in local replica for 1–5 seconds. Tools return stale data. | No — queries succeed | Health endpoint tracks last_synced_at; alert if lag > 30s |
| Free tier database deletion (10-day idle) | Turso deletes the database after 10 days without a connection. All queries fail with "database not found". | No — server process alive | /health/turso query fails; send a keep-alive query every 7 days |
| Database over storage limit (free tier: 1GB) | INSERT operations start failing. SELECT still works. MCP write tools fail silently. | No | Track rowsAffected === 0 on expected inserts; catch storage limit errors |
Frequently asked questions
Turso vs plain SQLite for non-edge MCP servers — which should I choose?
If your MCP server is a single Node.js process on a traditional server (not edge/serverless), plain SQLite via better-sqlite3 is simpler: synchronous API, no auth tokens, no network round-trips, and sub-millisecond reads. Use Turso when you need: (a) distributed replicas for low latency across regions, (b) multiple MCP server instances accessing the same database without file sharing, (c) edge runtime compatibility (Cloudflare Workers, Deno), or (d) branching for development workflows. Turso's libSQL is wire-compatible with SQLite, so you can start with file: locally and switch to libsql:// for production with no SQL changes.
How do I automate auth token renewal?
Turso tokens can be configured with an expiry (e.g., 90 days) or set to never expire. For production MCP servers, use a long expiry or never-expiring token and rotate it manually on a schedule. For automated renewal: the Turso API supports creating tokens programmatically — add a cron job or Lambda that calls POST /v1/organizations/{org}/databases/{db}/auth/tokens every 60 days and updates the TURSO_AUTH_TOKEN environment variable in your deployment platform (e.g., AWS Secrets Manager, Render environment variables, Cloudflare Workers secrets via wrangler secret put). After updating the secret, redeploy or restart the MCP server process. Your /health/turso endpoint will surface the 401 immediately if the old token expires before renewal completes.
Can I use multi-tenant database-per-tenant with Turso?
Yes — this is one of Turso's designed use cases. Turso supports thousands of databases per organization on paid plans. For multi-tenant MCP servers: create one Turso database per tenant on account provisioning, store the database URL in your tenant registry, and instantiate a createClient({ url: tenantDbUrl, authToken }) per request. Turso supports per-database or organization-wide auth tokens — use organization-scoped tokens to avoid managing thousands of individual tokens. The MCP server tool handler looks up the tenant's database URL from the registry (a separate Postgres or Redis lookup), instantiates a client for that request, and closes it after. Because the HTTP client requires no persistent connection, there's no connection pool to manage per tenant.
Can I use Drizzle ORM with Turso libSQL?
Yes. Drizzle has a first-class Turso adapter: import { drizzle } from 'drizzle-orm/libsql' with const db = drizzle(tursoClient). Define your schema with Drizzle's SQLite schema syntax (sqliteTable, text, integer, etc.) and use Drizzle's query builder for type-safe SQL. Schema migrations work via drizzle-kit push (for development) or drizzle-kit generate + drizzle-kit migrate (for production). The Drizzle layer adds no significant overhead over the raw libSQL client since it generates parameterized SQL and passes it through to Turso. One caveat: Drizzle's transaction API wraps Turso's batch(), which works in HTTP mode, but the API is slightly different from pool.transaction() in Postgres drivers — read the Drizzle libSQL docs for the correct transaction syntax.
Further reading
- MCP Server SQLite — better-sqlite3 and local SQLite for MCP servers
- MCP Server Cloudflare Workers — edge MCP deployment with Workers and Durable Objects
- MCP Server Drizzle ORM — type-safe SQL for MCP tool handlers
- MCP Server Health Check — designing endpoints for uptime monitors
- MCP Server Cold Start — reducing startup latency for edge and serverless MCP