This guide applies to the v2 -> v3 transition. Future major-version migrations will be documented in separate guides.
The context-entry primary key is a UUIDv7 value. On SQLite the context_entries.id column is TEXT NOT NULL UNIQUE; on PostgreSQL it is a native UUID NOT NULL PRIMARY KEY. Public IDs are exchanged at MCP tool boundaries as 32-character lowercase hex strings (no hyphens).
If you previously ran an installation whose context_entries.id column was an INTEGER (SQLite) or BIGSERIAL (PostgreSQL) and you want to keep your existing data, you must run the mcp-context-server-migrate CLI on a backup of your source database before pointing the new server at it. This file walks through that process.
You need to migrate if all of the following apply:
- You ran a previous installation of this server and have a SQLite database file or a PostgreSQL instance with stored context entries.
- The
context_entries.idcolumn in that database is INTEGER (SQLite) or BIGINT / BIGSERIAL (PostgreSQL). - You want to keep your existing data when upgrading to the UUIDv7-keyed schema.
If you are starting fresh (no existing data) you do NOT need to migrate. Install the new server, point it at an empty database location, and proceed normally.
Before running the CLI:
- Stop the server. The migration tool requires exclusive read access to the source database.
- Make a backup copy of the source database. The migration reads the source in read-only mode (SQLite uses the
mode=roURI parameter; PostgreSQL usesBEGIN TRANSACTION READ ONLY), but a backup is still essential in case of an interrupted run or operator error. - Provision an empty target database. For SQLite, a non-existent file path is fine -- the CLI creates the file and applies the current schema. It must be EMPTY or non-existent, not a database a v3 server has already initialized: compression is default-ON in v3, so such a target already carries compression provenance, and the CLI REFUSES the run rather than copy fp32 vectors the next server start would destroy (a later
--compresscannot rescue them -- it is a no-op while a provenance row exists). If you must reuse an initialized target, runmcp-context-server-migrate --source-url <target> --decompressagainst it first to clear its compression provenance, then rerun the migration and finish with--compress. For PostgreSQL, create an empty database manually (for example,CREATE DATABASE mcp_context_v3;); the CLI does not create the PostgreSQL database itself, but it now auto-initializes the target schema (tables, indexes, and functions) on an empty target, mirroring the SQLite path. You no longer need to start the server once against the target to create the schema -- if the target has nocontext_entriestable, the CLI builds it before copying. When the source carries embeddings, the auto-initialized target gets the fp32 vector layout (the CLI never enables compression during initialization); enable compression afterward with the separate--compressstep (see Compressing an Existing Database). - For SQLite source databases that use semantic search or FTS, ensure the
sqlite-vecextension is available in the Python environment running the CLI. The CLI loads the extension on both source and target connections when vector tables are present. - For PostgreSQL targets that should use semantic search after migration, ensure the
pgvectorextension is enabled. When the source has embeddings, the CLI runsCREATE EXTENSION IF NOT EXISTS vectoron the target during auto-initialization; this requires sufficient privileges. On managed services that restrictCREATE EXTENSION(notably Supabase), enable pgvector first via the database management interface (Supabase: Dashboard -> Database -> Extensions -> vector), then rerun. - Protect PostgreSQL credentials. Avoid placing passwords directly on the command line. Prefer environment-variable substitution or a
.pgpassfile. URLs printed by the CLI are masked in stdout (postgresql://user:***@host/db), but passwords on the original command line remain visible in process listings and shell history.
The CLI is installed alongside the server. If you installed mcp-context-server via uv or pip, mcp-context-server-migrate is on your PATH (it is registered in [project.scripts]). Verify with:
mcp-context-server-migrate --helpmcp-context-server-migrate \
--source-url sqlite:////path/to/source.db \
--target-url sqlite:////path/to/target.dbThe bare filesystem path form is also accepted:
mcp-context-server-migrate \
--source-url /path/to/source.db \
--target-url /path/to/target.dbNote that sqlite:/// requires three slashes followed by an absolute path. The CLI also accepts plain filesystem paths without a scheme.
mcp-context-server-migrate \
--source-url postgresql://user:password@host:5432/old_db \
--target-url postgresql://user:password@host:5432/new_dbPasswords in printed URLs are masked in stdout output (postgresql://user:***@host/db).
The CLI honors the same POSTGRESQL_* environment variables the server uses for every PostgreSQL connection it opens (source, target, and the auto-initialization connection):
- Schema (
POSTGRESQL_SCHEMA). The CLI appliessearch_path = "<POSTGRESQL_SCHEMA>", publicon every connection, so a non-default schema is resolved correctly for both reads and the auto-initialized target. SetPOSTGRESQL_SCHEMAto the same value the server will use. - Connection poolers (
POSTGRESQL_STATEMENT_CACHE_SIZE). The CLI sends no PostgreSQL startup parameters (it appliessearch_pathand the other session parameters withSETafter connecting), so no pooler allowlist entry is needed for it. It does use prepared statements by default (cache size 100). Transaction-mode poolers -- PgBouncer transaction mode, Pgpool-II, AWS RDS Proxy, and the Supabase Transaction Pooler (port 6543) -- break prepared statements. For those, either run the migration through a session-capable endpoint (a Direct connection or the Supabase Session Pooler, both on port 5432) or setPOSTGRESQL_STATEMENT_CACHE_SIZE=0. For Supabase specifically, prefer the Direct connection or Session Pooler URL on port 5432. - SSL. SSL is taken from the connection URL. Put
?sslmode=require(or your chosen mode) directly in--source-url/--target-url; the CLI does not injectsslmodefrom settings because the source and target may be different databases than the running server.
The CLI supports SQLite -> PostgreSQL and PostgreSQL -> SQLite. Vector embeddings (the binary embedding BLOBs in SQLite's vec_context_embeddings table and the corresponding PostgreSQL pgvector values) are NOT portable across backends because their binary representations differ. When cross-backend migration is requested, the CLI emits a warning and DROPS only the vector embeddings -- you must re-embed the target database after migration (either by storing entries again or by running the standard server startup path with embedding generation enabled, which fills in missing embeddings for entries that lack them).
All other data is copied: context_entries (with summary and content_hash), tags, and image_attachments (image payloads are portable -- BYTEA on PostgreSQL maps to BLOB on SQLite). The target schema is auto-initialized when absent (the SQLite target via the existing initializer; the PostgreSQL target via the base schema, without the vector tables, which the server creates at your configured EMBEDDING_DIM when you re-embed). Full-text search is provisioned on the target regardless of direction even though the FTS data itself is not portable: for PostgreSQL -> SQLite the SQLite target's FTS5 index is rebuilt from the copied rows, and for SQLite -> PostgreSQL the PostgreSQL target's text_search_vector is a generated column created during target initialization, so it is populated automatically as rows are copied (no separate rebuild step).
A SQLite source may hold a value that SQLite stores happily but a PostgreSQL target cannot bind. Two byte sequences are unstorable in any column: an embedded NUL (U+0000) and an unpaired UTF-16 surrogate (a code point that cannot be encoded as UTF-8). The jsonb columns -- context_entries.metadata and image_attachments.image_metadata -- carry two further hazards: a metadata NUL serialized as its six-character JSON escape sequence (which the target's ::jsonb cast rejects even though the raw bytes contain no literal NUL), and metadata that is not valid JSON at all (which the ::jsonb cast also rejects). Any of these, bound blindly, would abort the whole cross-backend transaction mid-run and roll back every already-copied row.
A THIRD class is about the target's INDEXES rather than its column types. PostgreSQL refuses to store an index tuple larger than roughly 2.7 KB, and it evaluates a metadata expression index -- including any SQL cast an integer/boolean/float METADATA_INDEXED_FIELDS type hint puts in it -- on every INSERT. SQLite's equivalent indexes have no size limit and apply no cast, so a source predating the write-path caps can hold an oversized thread_id, an oversized tag, or an indexed metadata value the target cannot index at all (an oversized one, or {"priority": "high"} under an integer-typed field). This class is checked in EVERY direction that writes to PostgreSQL, including PostgreSQL to PostgreSQL: the metadata expression indexes are deliberately absent from the base schema, so a source that was only ever initialized by this CLI can carry such a value even though it is itself a PostgreSQL database.
Rather than crash, the CLI pre-checks each source row and SKIPS an offending row instead of aborting: it records the source row id and the offending column in the run's errors, and skips that row's tags and image_attachments children too (with a warning) so no child is left referencing a never-inserted parent (which would itself raise a foreign-key violation on PostgreSQL). Clean rows in the same run still migrate and the transaction commits. --dry-run surfaces every such row without inserting, so you can preview the full set before a real run.
Because each skipped row is recorded as an error, a run that skips any row exits with a nonzero status (exit 1), so a scripted invocation does not mistake a partial migration for a clean one. Inspect the printed summary (or the --report PATH JSON) for the column ... skipped: ... entries, repair the offending values at the source -- shorten an oversized one, correct a value its field's type hint rejects -- and re-run to migrate the previously skipped rows.
--dry-run-- run the full migration logic in memory (read source, build the integer-to-UUIDv7 mapping, compute target rows) but issue zero INSERT statements against the target. Useful for validating that the source database is well-formed before committing to a real run.--report PATH-- after the run completes, write the run's statistics (rows migrated, references rewritten, orphan references detected, warnings, errors) as JSON toPATH. If omitted, a human-readable summary is printed to stdout.
The CLI performs the following steps in order:
-
Read source rows in deterministic order. Reads from
context_entriesordered by(created_at ASC, id ASC). The deterministic ordering ensures the in-memory integer-to-UUIDv7 mapping is reproducible across runs. -
Generate a UUIDv7 per row from each row's
created_at. Usesuuid_utils.uuid7(timestamp=int(created_at.timestamp()), nanos=created_at.microsecond * 1000). Thetimestampparameter is in UNIX SECONDS with an optional nanosecond fraction; passing milliseconds in thetimestampslot would shift the embedded timestamp roughly 1000x further into the future, producing UUIDs whose decoded year is around 50,000 AD. Upstream tracker documenting this confusion source:aminalaee/uuid-utils#73. The resulting UUIDs preserve the source's chronological order: lex-string comparison on the 32-character lowercase hex form is monotonic with respect tocreated_atat millisecond granularity. Sub-millisecond ordering is determined by the UUID random tail, which is acceptable for the server's deduplication interleaving check. -
Rewrite
metadata.references.context_idsarrays. For each row, walks the parsedmetadataJSON recursively, finding everyreferences.context_idsarray. Integer entries are replaced with the mapped UUIDv7 hex string. String entries are preserved unchanged (treated as already-UUID values). Integer entries that do not match any source row are kept as integers and flagged in the run summary asorphan_references(see Troubleshooting below). Malformed shapes (for example, a non-arraycontext_idsvalue) are flagged asmalformed_referenceserrors, and the row's metadata is copied verbatim. -
Copy free-form text byte-for-byte. The
text_contentandsummarycolumns are copied unchanged. Any mention of an integer ID inside the prose (for example, "see entry 8944") is left as-is. The CLI does not attempt to rewrite free-form text -- such substrings become inert references after migration, which is the intended outcome (eliminating false positives from regex-style ID detection across the corpus). -
Copy tags, image attachments, embedding metadata, embedding chunks, and vector rows. Only the
context_idforeign-key column is remapped to the new UUIDv7 hex value. Internal embedding identifiers (embedding_chunks.id,embedding_chunks.vec_rowid, vec0 rowids,vec_context_embeddings.id) are preserved verbatim because the project's chunking-layer architecture uses INTEGER bridges to vec0; only the outercontext_idforeign key changes type. -
Rebuild the SQLite FTS5 index. When the source has
context_entries_fts, the CLI issuesINSERT INTO context_entries_fts(context_entries_fts) VALUES('rebuild')against the target to re-index the copied rows. The FTS5 rowid alignment withcontext_entries.rowid_intis preserved. -
Print or write the run report. A summary of counters (rows migrated, references rewritten, orphan references, malformed references, tags/images/embeddings copied, FTS rebuild status, warnings, errors) is printed to stdout. If
--report PATHis set, the same data is also written as JSON.
If you are running PostgreSQL 18 or later, you have the OPTION to use the server-side uuidv7() built-in for the id column default:
ALTER TABLE context_entries ALTER COLUMN id SET DEFAULT uuidv7();This is a pure operator-side optimization and is NOT required for correct operation. The application generates UUIDs Python-side via the app.ids module by default; the column default would only matter if external clients ever insert rows without specifying an id. Most installations should leave this column default unset. PostgreSQL 17 and earlier do not have the uuidv7() function; users on those versions cannot apply this optimization.
If the run summary shows orphan_references > 0, your source database contained metadata.references.context_ids entries pointing to IDs that do not exist in context_entries (most commonly from older rows that were deleted before migration). The orphan IDs are preserved as integers in the migrated metadata; downstream consumers that interpret references.context_ids should be prepared to encounter both string UUIDs and stray integers in legacy data. To find and clean orphan references, query the migrated database after the run -- the JSON references.context_ids arrays are still queryable with array_contains operators.
If the CLI prints source database file does not exist: <path>, the path passed to --source-url does not exist on disk. For SQLite, ensure the path is correct and absolute. For sqlite:/// URLs, note that three slashes are required: sqlite:///C:/Users/me/db.sqlite on Windows or sqlite:////home/me/db.sqlite on Linux.
The CLI refuses to write to a non-empty SQLite target. If you see target database already contains context_entries rows: <path>, delete the target file (or specify a different --target-url) and rerun.
The CLI does not currently support resume-from-checkpoint. If a migration is interrupted mid-run (Ctrl+C, process kill, machine power loss, network drop on PostgreSQL), the source database is unaffected -- it was opened read-only -- and the target database is left in one of two recoverable states described below.
The recovery procedure is the same in both cases: delete the target database and rerun the same mcp-context-server-migrate command. The source is unchanged, so a fresh run reads the same input data and produces the same target. Note that the lower 74 random bits of each UUIDv7 will differ from any prior interrupted run, but the embedded millisecond timestamps and the row-by-row chronological ordering are identical, which is the only property the server depends on.
State 1: Empty or schema-only target.
The data-copy transaction rolled back. The target file (SQLite) or database (PostgreSQL) may contain the empty schema (tables defined but no rows) or may not exist at all, depending on how far the CLI got before the interrupt. Recovery:
- SQLite:
rm /path/to/target.db-- then rerun. Alternatively, pass a different--target-urlto the rerun. - PostgreSQL:
DROP DATABASE new_db; CREATE DATABASE new_db;-- then rerun. Alternatively, point--target-urlat a different empty database.
State 2: Data committed but FTS index stale (SQLite only).
There is a narrow window after the main data transaction commits but before the FTS5 rebuild commits. If the interrupt happens inside that window, the target contains all migrated rows but its context_entries_fts virtual table is stale or empty. This is intentional: the FTS rebuild runs outside the main data transaction so that a failing FTS rebuild does not destroy the row data. Two recovery options:
-
Clean rerun:
rm /path/to/target.dband rerun the CLI. This is the simplest option and is always correct. -
In-place FTS rebuild: run the SQLite shell command below to re-index the existing target without redoing the row copy:
sqlite3 /path/to/target.db "INSERT INTO context_entries_fts(context_entries_fts) VALUES('rebuild')"This is the same idempotent rebuild directive that the CLI itself issues, so running it manually is safe.
PostgreSQL does not have a State 2 equivalent. Its text_search_vector column is a generated column maintained by the row INSERT, so a successful main transaction also produces a complete tsvector index.
Preflight tip. If you want to verify a migration will succeed before committing to a real run, pass --dry-run on a first invocation. The dry run reads the source, builds the integer-to-UUIDv7 mapping, and computes target rows in memory; it does NOT INSERT into the target and writes nothing to the target path. On SQLite the dry run operates against an ephemeral in-memory database, so it leaves no file (and no schema) at the target path; on PostgreSQL the target database is left empty. A subsequent real run against the same target proceeds normally -- no delete or rename is needed.
If you ran a cross-backend migration (SQLite to PostgreSQL or vice versa) and saw the warning cross-backend migration drops vector embeddings; re-embed the target after migration, your target database contains all rows and metadata but no vector embeddings. To restore embeddings, in order of preference:
- Recommended: Run
mcp-context-server-migrate --source-url <target-url> --embed-missingagainst the target database. The--embed-missingflag invokes the configured embedding provider (EMBEDDING_PROVIDER,EMBEDDING_MODEL) for every entry that lacks anembedding_metadatarow. WhenENABLE_EMBEDDING_COMPRESSION=trueis set (the default in v3.0.0), the new embeddings land directly invec_context_embeddings_compressed; otherwise they land invec_context_embeddings. Run with--dry-runfirst to count missing entries without calling the provider. See the Backfilling missing embeddings section of the Embedding Compression Guide for details. - Re-store the affected entries via the live server (each successful
store_contextcall triggers embedding generation when generation is enabled), OR - Run
mcp-context-serveragainst the target withENABLE_EMBEDDING_GENERATION=trueand trigger any operation that touches the affected entries.
If the run summary or log shows that the source database has second-precision created_at (no microsecond data), the CLI prints an informational log line. Same-second entries in the source are still uniquely identified (the UUIDv7 random tail differentiates them), but the resulting ordering of same-second entries may differ from the source's id-based ordering. This is acceptable per RFC 9562 and the server's deduplication invariants.
Some online guides suggest using SQLite's writable_schema pragma to ALTER the context_entries.id column type in-place from INTEGER to TEXT. Do not do this. It is documented as unsafe by the SQLite project, and it would not migrate the actual integer values to UUIDs anyway -- it would only change the column declaration while leaving every row's id as an integer, breaking referential integrity. The export-then-import path implemented by mcp-context-server-migrate is the correct approach.
Suggested verification steps after a successful migration:
- Start the server pointed at the target database. Confirm it starts cleanly (no schema-mismatch errors at startup).
- Run a smoke test query:
list_threadsor a simplesearch_contextcall. Confirm the response includes 32-character hexcontext_idvalues. - If FTS was enabled on the source, run a small
fts_search_contextquery and confirm results are returned. - If semantic search was enabled on the source AND the migration was same-backend (no embeddings dropped), confirm a
semantic_search_contextquery returns expected results. - Spot-check that
metadata.references.context_idsarrays contain UUIDv7 hex strings (not integers) in a few migrated rows.
A one-liner SQLite sanity check that confirms the id column is TEXT in the target:
python -c "import sqlite3; c = sqlite3.connect('target.db'); rows = c.execute(\"SELECT typeof(id) FROM context_entries LIMIT 1\").fetchall(); assert rows and rows[0][0] == 'text', rows"If you want to enable the optional ENABLE_EMBEDDING_COMPRESSION feature on a database that already contains fp32 embeddings, run the mcp-context-server-migrate CLI with the --compress flag. This step is independent of the v2-to-v3 schema migration described above: if your database came from a v2 installation, run the v2-to-v3 migration first, then optionally run --compress on the resulting v3 database.
Order matters, and the CLI enforces it: --compress is a no-op once a compression_metadata provenance row exists, so it cannot rescue vectors copied into an already-compressed target. That is why a migration whose source carries embeddings is refused when the target is already configured for compressed embeddings (see the Pre-Migration Checklist). Migrate into an empty target first, then compress.
For background on what compression does, how it is configured, and the multi-pod seed-locked invariant, see the Embedding Compression Guide.
Warning
The --compress flag is destructive: it permanently drops the vec_context_embeddings fp32 table and replaces it with vec_context_embeddings_compressed. The original fp32 vectors are NOT recoverable from the compressed payload (the compression is lossy by design). Back up your database before running the CLI.
- SQLite: take a filesystem copy of the
.dbfile. - PostgreSQL: run
pg_dumpto a portable archive.
Always run --dry-run before the real execution to preview the operation:
mcp-context-server-migrate \
--source-url sqlite:////path/to/db.sqlite \
--compress \
--dry-runThe dry run prints the BACKUP REQUIRED warning, the source URL (with credentials masked), the row count, the destination table name, the singleton provenance values that will be recorded, and an estimated execution time computed from a probe batch. No writes are issued.
After verifying the dry-run plan, export the compression env vars and re-run without --dry-run:
# All four export lines are optional in v3.0.0; the values shown are the defaults.
# Override only if you intentionally want a different configuration.
# export ENABLE_EMBEDDING_COMPRESSION=true
# export COMPRESSION_BITS=4
# export COMPRESSION_VARIANT=ip
# export COMPRESSION_SEED=0 # immutable after first compressed row is written
mcp-context-server-migrate \
--source-url sqlite:////path/to/db.sqlite \
--compressThe CLI streams fp32 rows in batches of 10 000, encodes each batch through the compression provider, and INSERTs each batch into vec_context_embeddings_compressed. The singleton compression_metadata INSERT and the source table DROP run LAST, all inside the same single atomic transaction. Peak working set is bounded by O(batch_size * dim * 4) bytes for the fp32 read window (approximately 40 MB at the default batch_size=10000 and dim=1024), independent of total row count. On PostgreSQL the idx_vec_context_embeddings_hnsw HNSW index is dropped before the source table. If any step fails before COMMIT, the transaction rolls back end-to-end: no resume marker, and the source vec_context_embeddings table remains intact. On PostgreSQL that is literally all of it -- the compressed-table and provenance-table creation shares the transaction (transactional DDL) and leaves no trace. On SQLite that DDL is idempotent IF NOT EXISTS schema run through executescript() BEFORE BEGIN IMMEDIATE opens, so it autocommits ahead of the streamed transaction: an aborted run can leave the empty vec_context_embeddings_compressed and compression_metadata tables behind, plus the nullable compression_metadata.codebook_fingerprint column on a database upgraded in place from before that column existed. None of it is harmful -- no rows were written, the fp32 source is untouched, and a re-run performs the full encode against the leftovers.
Top-K recall against fp32 ground truth is at least 0.85 per query at the default configuration (the project's regression gate; measured recall is 1.0 on the test corpus).
Start the server with the same compression env vars exported. The server reads the seed from the compression_metadata row and serves search through the compressed read path. A mismatch between the env vars and the stored row raises ConfigurationError (exit 78) and refuses startup.
The --decompress flag reverses the operation, decoding the compressed payload back to fp32 and recreating the fp32 vec table. Reconstruction is lossy: the decoded vectors approximate the original MSE component (for variant='mse') and cannot perfectly reconstruct the inner-product information (for variant='ip'). Use it only when you intend to abandon compression on a given database.
--decompress follows the same streaming + single-transaction shape as --compress: compressed rows are read in batches of 10 000, decoded per batch, and used to rebuild the fp32 layout directly from each compressed row's own context_id/start_index/end_index. On SQLite this rebuilds BOTH the recreated vec_context_embeddings table and the embedding_chunks bridge (linked through a fresh vec_rowid), so the reversal does not depend on any pre-existing embedding_chunks rows; on PostgreSQL the fp32 read path joins by context_id, so only vec_context_embeddings is recreated. The source compressed table DROP and (on PostgreSQL) HNSW index recreation run LAST inside the same atomic transaction. The same peak-memory bound and the same rollback contract apply, including the SQLite qualification: the vec_context_embeddings CREATE VIRTUAL TABLE likewise autocommits before BEGIN IMMEDIATE, so an aborted --decompress on SQLite can leave that table present and empty (harmless, and a re-run proceeds normally against it), while on PostgreSQL the equivalent DDL rolls back with everything else.
Unset ENABLE_EMBEDDING_COMPRESSION first so the post-decompression startup validator does not reject the disabled state:
unset ENABLE_EMBEDDING_COMPRESSION
mcp-context-server-migrate \
--source-url sqlite:////path/to/db.sqlite \
--decompressOn PostgreSQL the idx_vec_context_embeddings_hnsw HNSW index is recreated after the fp32 table is rebuilt.
The compression CLI is single-backend. If you are migrating from SQLite to PostgreSQL (or vice versa), run the v2-to-v3 cross-backend migration first, then run --compress on the target.
Running --compress against an already-compressed database is a no-op: the CLI detects the singleton compression_metadata row, prints an informational message, and exits successfully. The same applies to --decompress when there is no compression to undo.
- Feature reference: Embedding Compression Guide
- Configuration: Environment Variables Reference
There are two distinct scenarios, and they have different procedures because changing the model and changing the dimension affect the vector storage differently.
Switching to a different embedding model that produces vectors of the SAME dimension (for example, replacing qwen3-embedding:0.6b with another 1024-dimensional model) is a one-command operation. --re-embed regenerates embeddings for EVERY context_entries row using the currently configured EMBEDDING_PROVIDER / EMBEDDING_MODEL, deleting the old vectors first and backfilling any entries that were missing embeddings along the way (it is a superset of --embed-missing). It works on both the fp32 and compressed layouts.
Steps:
-
Stop the server and back up the database (
--re-embeddeletes and rewrites every embedding). -
Set the new
EMBEDDING_MODELin the environment, leavingEMBEDDING_DIMunchanged, and ensureENABLE_EMBEDDING_GENERATION=true. -
Preview with
--dry-run-- it reports how many entries will be re-embedded and the existing model(s) being replaced, without calling the provider:mcp-context-server-migrate --source-url sqlite:////path/to/db.sqlite --re-embed --dry-run
-
Execute by re-running without
--dry-run. Each entry's delete + regenerate runs inside one transaction, so an entry is never left without embeddings. On a compressed database the new payloads land invec_context_embeddings_compressed; on an fp32 database they land invec_context_embeddings. -
Restart the server with the new
EMBEDDING_MODEL(and the sameCOMPRESSION_*values, if compression is enabled).
--re-embed requires ENABLE_EMBEDDING_GENERATION=true, is mutually exclusive with --compress / --decompress, and supersedes --embed-missing. Related: the --embed-missing flag now refuses to backfill into a database whose existing embeddings use a different model or dimension than the configured values (mixing embedding spaces would corrupt semantic search); it points you here when it detects a model change.
Changing EMBEDDING_DIM is heavier and --re-embed deliberately refuses it. The dimension is baked into the vector-storage geometry: the fp32 vec_context_embeddings column width is fixed at table creation, and under compression the dimension is part of the seed-locked compression_metadata codebook (immutable by design). A dimension change therefore requires recreating the vector storage from scratch, not an in-place re-embed. Follow the Changing Embedding Dimensions procedure: back up, update the configuration, delete the database (or drop and recreate the vector tables at the new dimension), restart the server to create fresh tables, and re-store the data so embeddings are generated at the new dimension.
This is the recommended order for moving an existing v2 deployment to a v3.0.0 Supabase (PostgreSQL) database with TurboQuant compression enabled. The key ordering rule is that compression is always the LAST step: the migration auto-initializes the target with the fp32 layout, and --compress converts it afterward.
- Provision the empty target database in Supabase and enable the
pgvectorextension (Dashboard -> Database -> Extensions -> vector). The CLI does not runCREATE DATABASE, and managed services restrictCREATE EXTENSION. - Configure the PostgreSQL connection for the CLI and server: set
STORAGE_BACKEND=postgresqlandPOSTGRESQL_CONNECTION_STRING. Use the Direct connection or Session Pooler (port 5432); if you must use the Transaction Pooler (port 6543), also setPOSTGRESQL_STATEMENT_CACHE_SIZE=0. SetPOSTGRESQL_SCHEMAto the schema the server will use. MatchEMBEDDING_MODEL/EMBEDDING_DIMto the source data. - Keep compression OFF for the migration: leave
ENABLE_EMBEDDING_COMPRESSION=false(or unset) so the target is auto-initialized with the fp32 vector layout the migration copies into. - Run the v2-to-v3 migration (preview first):
mcp-context-server-migrate --source-url <v2-url> --target-url <v3-supabase-url> --dry-run, then without--dry-run. A same-backend (PostgreSQL -> PostgreSQL) migration copies the embeddings; a cross-backend migration (SQLite -> Supabase) drops them. - Restore embeddings if they were dropped (cross-backend only): run
mcp-context-server-migrate --source-url <v3-supabase-url> --embed-missingto regenerate them with the configured provider. (If you also intend to switch models, run--re-embedinstead.) - Enable compression as the final step: export
ENABLE_EMBEDDING_COMPRESSION=trueand theCOMPRESSION_*values, then runmcp-context-server-migrate --source-url <v3-supabase-url> --compress --dry-runfollowed by--compress. In multi-pod Kubernetes deployments every pod MUST inherit the sameCOMPRESSION_SEED. - Launch the v3.0.0 server with
ENABLE_EMBEDDING_COMPRESSION=trueand the sameCOMPRESSION_SEED/COMPRESSION_BITS/COMPRESSION_VARIANT. The server reads the seed from the singletoncompression_metadatarow; a mismatch raisesConfigurationError(exit 78) and refuses to start.
If the migration outcome is unsatisfactory, the source database (untouched by the CLI's read-only connection) remains intact. Point the previous server version at the source database to revert. The target database can be deleted.