SQLite

@FrontMcp({ sqlite }) keeps the state a server holds between requests (sessions of clients before MCP 2026-07-28, background tasks and questions waiting for an answer) in a SQLite file instead of the process's memory, so it survives a restart. It needs no server of its own, only a disk that lasts. It's for one machine: a server on a laptop or a VM, a local daemon, a desktop client's server. Several machines share state through Redis instead.

@FrontMcp({ sqlite: { path?, encryption?: { secret }, walMode?, ttlCleanupIntervalMs?, busyTimeoutMs? } })

Reference

sqlite

npm install @frontmcp/storage-sqlite
main.ts
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  sqlite: { path: "./data/desk.sqlite" },
})
export default class Server {}

See more examples below.

@frontmcp/storage-sqlite brings better-sqlite3, a native module, with it. FrontMCP's own session store loads better-sqlite3 directly, and local and remote auth's tokenStorage: { sqlite } loads @frontmcp/storage-sqlite; installing the first gives you both. better-sqlite3 downloads or compiles its binary when it's installed, so install it on the platform it runs on: npm ci inside the Docker image, not on your laptop.

Options

OptionTypeDefaultWhat it does
pathstringSee where the file goesThe database file. Its folder is created if it's missing. FRONTMCP_SQLITE_PATH overrides it.
encryption{ secret: string }noneEncrypt every stored value with AES-256-GCM, with a key derived from secret. Keys stay readable.
walModebooleantrueSQLite's write-ahead log: the file gets -wal and -shm companions.
ttlCleanupIntervalMsnumber60000How often expired entries are deleted.
busyTimeoutMsnumber5000How long a write waits for a lock another process holds before it fails (SQLite's busy_timeout, set before anything else touches the file). Since 1.9.

With another process holding a write lock for 3 seconds, an initialize waited for it and its session was stored; with busyTimeoutMs: 1000 it gave up after a second, and the log said Failed to persist session to SQLite { …, error: 'database is locked' }. The option is also in transport.persistence.sqlite, tasks.sqlite and auth.tokenStorage.sqlite. Changed in 1.9: before, @FrontMcp({ sqlite }) dropped busyTimeoutMs, and the wait was always 5 seconds.

Where the file goes

WhenWithout path
Outside production, started with node or frontmcp dev<project>/dist/sessions.sqlite
In production~/.<info.name>/sessions.sqlite, like ~/.help-desk/sessions.sqlite

FRONTMCP_SQLITE_PATH beats both, and path too: with FRONTMCP_SQLITE_PATH=./other/override.sqlite the file was created there and ./data/desk.sqlite wasn't. The server logs the path it chose: No `sqlite.path` set — using default { path: '…' }. In a container, ~ is the home folder of the user the process runs as, inside the container: mount a volume there, or set path to one, or the file is lost with the container.

What's stored

With sqlite set, the server logs sqlite session store will be initialized for transport persistence, Created SQLite task store, and with elicitation on, Created elicitation store { type: 'sqlite' }. The file has two tables:

TableHolds
kv (key, value, expires_at)Sessions, as mcp:transport:<session id>, and pending questions, under mcp:elicit:. Each value is JSON, or iv:tag:ciphertext in base64url with encryption.
mcp_tasksBackground tasks, one row each.

A session expires an hour after it's written, as in Redis; transport.persistence.defaultTtlMs changes that. Sessions of clients on MCP 2026-07-28 don't exist, so their calls write nothing unless a tool starts a task or asks a question.

sqlite isn't used by auth.tokenStorage or throttle.storage, which have their own options (Local auth, Guard options).

Caveats

  • One machine. Two processes can share a file on the same disk, and one serves the other's sessions. Two that opened it at the same moment both started, in three tries; a write waits up to busyTimeoutMs when the other process holds the lock, and only after that fails with database is locked: the session isn't stored, the log says Failed to persist session to SQLite { …, error: 'database is locked' } (it said Redis in 1.8.7), and the call itself is served. Changed in 1.8.7: before, the second process failed straight away with Failed to create session store - session persistence disabled { error: 'database is locked' } and kept sessions in memory.
  • A lost encryption.secret loses the data, and a changed one breaks it: a client whose session was stored with the old secret gets 500 Internal Server Error, and the log says SqliteDecryptionError: SqliteKvStore: failed to decrypt a stored value. The encryption secret differs from the one the database was written with (or the value is corrupted). Restore the original secret, or delete the database file to start empty., with the code SQLITE_DECRYPTION_FAILED. New sessions work. There's no way to re-encrypt.
  • SQLite doesn't run on Cloudflare Workers, and the Cloudflare build refuses a server that names sqlite; on Vercel and Lambda each instance has its own disk, so a file doesn't help.
  • The Playground has no SQLite, so nothing here runs on this page. It was run with FrontMCP 1.9.2, @frontmcp/storage-sqlite 1.9.2 and better-sqlite3 12.11, on macOS, and the tables are what better-sqlite3 read back from the file: stored sessions across a restart, a second process on the same file serving a session, a write held up by another process's lock, with and without busyTimeoutMs, FRONTMCP_SQLITE_PATH, a changed encryption.secret, and the default path in development. Two processes opening the file at the same moment and the production path were last run with 1.8.7, and the container steps with 1.8.4.

Usage

Keeping sessions across restarts

With the configuration at the top of the page, start the server with MCP_SESSION_SECRET set, and let a client on MCP 2025-06-18 initialize. Its session is a row in the file:

node -e "const D = require('better-sqlite3'); console.log(new D('./data/desk.sqlite', { readonly: true }).prepare('select key, expires_at from kv').all())"
[ { key: 'mcp:transport:<session id>', expires_at: 1790000000000 } ]

Stop the server and start it again with the same secret: the client's next tools/call with the same Mcp-Session-Id succeeds, and the server logs Recreating transport from stored session. Without sqlite, it would get 404 session not initialized. Without the secret, outside production, the restarted server can't read the old id and answers 404 invalid session id, sqlite or not: see Redis.

Encrypting what's stored

Read the secret from the environment, and keep it where you keep the server's other secrets:

main.ts
@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  sqlite: {
    path: "./data/desk.sqlite",
    encryption: process.env.DESK_DB_SECRET ? { secret: process.env.DESK_DB_SECRET } : undefined,
  },
})
export default class Server {}

The row's value is then <iv>:<tag>:<ciphertext>, three base64url parts, instead of JSON. The keys, and so the session ids, stay readable: they're how rows are found.

Keeping the file in a container

Put it on a volume, at a path you choose:

sqlite: { path: "/data/desk.sqlite" },

A new Docker volume takes the owner of the folder it's mounted on, and a folder that isn't in the image belongs to root, so a server that runs as node can't write there. Create it in the image, in the Dockerfile, before USER node:

ci/Dockerfile
RUN mkdir -p /data && chown node:node /data
USER node
docker run --init -v desk-data:/data -e MCP_SESSION_SECRET="$(openssl rand -hex 32)" help-desk

A session then survives docker restart. The image needs better-sqlite3 built for its own platform: install dependencies in the image, as that Dockerfile does, rather than copying node_modules from another machine.


Troubleshooting

Error: Cannot find module 'better-sqlite3'

The server has sqlite set, and the package isn't installed. It logs Failed to create session store - session persistence disabled and then exits. npm install @frontmcp/storage-sqlite, which installs better-sqlite3 too.

failed to open database at "/data/desk.sqlite": unable to open database file

The process can't create or write the file, most often a container user without write access to a mounted volume. The server exits, with SqliteTaskStore: failed to open database at …. Give the user the folder, as in keeping the file in a container.

Failed to persist session to SQLite { error: 'database is locked' }

Another process held a write lock on the file for longer than a write waits, busyTimeoutMs, 5 seconds by default. The session isn't stored, though the request is served. Find the process that holds the file, raise busyTimeoutMs, give each process its own path, or move to Redis for several. In 1.8.7 the line said Failed to persist session to Redis, though the store was SQLite. (Before 1.8.7 the second process failed to open the file at all, with Failed to create session store - session persistence disabled { error: 'database is locked' }.)

500 Internal Server Error, and SqliteDecryptionError in the log

The stored value was encrypted with another encryption.secret. Put the old secret back, or delete the file and let clients start new sessions. (Before 1.8.7 the log said Unsupported state or unable to authenticate data.)

[--target cloudflare] config incompatible with Cloudflare Workers: … sqlite storage is not supported

Workers have no file system for SQLite. Remove sqlite from the Worker's configuration. See Cloudflare Workers.

The file is somewhere unexpected

Without path, the file is in dist/ in development and in ~/.<info.name>/ in production, unless FRONTMCP_SQLITE_PATH is set. The startup log says which. Set path to be sure.