feat: per-group Supabase isolation via project or schema#58
Merged
Conversation
Lets each vCon group run as its own MCP instance pointed at a separate Supabase project (physical isolation) or a separate Postgres schema in a shared project (logical isolation), selectable per instance. - client.ts: honor SUPABASE_DB_SCHEMA (default public), pass db.schema to createClient so .from()/.rpc() route to the group's schema - index.ts: ENV_FILE-aware dotenv load so each instance loads .env.<group>; instance label + schema in startup banner - schema.ts: surface instance + schema in GET /health - package.json: start / start:group scripts - scripts/start-group.sh: launch an instance for a named group - scripts/bootstrap-schema.sh: clone public into a new schema (review-then-apply) - scripts/migrate-all-groups.sh: fan out migrations across all .env.<group> - docs/guide/multi-supabase-isolation.md + sidebar entry; .env.example vars Build green; 738 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Enables running one vCon MCP server instance per “group”, isolating data either by separate Supabase projects (different SUPABASE_URL) or by separate Postgres schemas within a shared project (SUPABASE_DB_SCHEMA), with per-instance env-file loading and operator tooling to bootstrap/migrate schemas.
Changes:
- Add
ENV_FILE-selectable dotenv loading and include instance/schema metadata in startup logs and/health. - Scope Supabase client queries/RPCs to a configurable Postgres schema via
createClient(..., { db: { schema } }). - Add operational scripts and documentation for starting per-group instances and bootstrapping/migrating per-schema setups.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/index.ts | Loads env from ENV_FILE and logs instance/schema on startup. |
| src/db/client.ts | Creates Supabase client scoped to SUPABASE_DB_SCHEMA. |
| src/api/routes/schema.ts | Surfaces instance and schema in GET /health. |
| scripts/start-group.sh | Starts a labeled instance with a per-group env file. |
| scripts/migrate-all-groups.sh | Fans out migrations across .env.<group> files. |
| scripts/bootstrap-schema.sh | Generates/applies SQL to clone public into a new schema. |
| package.json | Adds start / start:group scripts for built server. |
| docs/guide/multi-supabase-isolation.md | Documents project vs schema isolation modes and ops flow. |
| docs/.vitepress/config.ts | Adds guide entry to the docs sidebar. |
| .env.example | Documents new env vars for multi-instance isolation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+36
to
+37
| echo "Starting vCon MCP instance: group=$GROUP env=$ENV_FILE" >&2 | ||
| exec node "$ROOT/dist/index.js" |
Comment on lines
+50
to
+68
| # Read just the two vars we care about without polluting the shell env. | ||
| url="$(grep -E '^\s*SUPABASE_URL=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' | xargs || true)" | ||
| schema="$(grep -E '^\s*SUPABASE_DB_SCHEMA=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' | xargs || true)" | ||
|
|
||
| echo "== group=$group url=${url:-<none>} schema=${schema:-public} ==" >&2 | ||
|
|
||
| if [[ -n "$schema" && "$schema" != "public" ]]; then | ||
| # Schema-isolated. | ||
| SQL="$ROOT/sql/bootstrap-$schema.sql" | ||
| if [[ ! -f "$SQL" ]]; then | ||
| echo " ! Missing $SQL — run scripts/bootstrap-schema.sh $schema first. Skipping." >&2 | ||
| continue | ||
| fi | ||
| DB_URL="${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}" | ||
| run psql "$DB_URL" -v ON_ERROR_STOP=1 -f "$SQL" | ||
| else | ||
| # Project-isolated. | ||
| run env ENV_FILE="$ENV_FILE" supabase db push | ||
| fi |
Comment on lines
+57
to
+61
| # Reject reserved / unsafe schema names. | ||
| if [[ "$SCHEMA" =~ [^a-zA-Z0-9_] || "$SCHEMA" == "public" || "$SCHEMA" == "extensions" || "$SCHEMA" == "auth" ]]; then | ||
| echo "Refusing schema name '$SCHEMA' (use a simple identifier; not public/extensions/auth)." >&2 | ||
| exit 1 | ||
| fi |
Comment on lines
+80
to
+83
| printf '%s\n' "$DUMP" \ | ||
| | sed -E "s/\bpublic\./${SCHEMA}./g" \ | ||
| | sed -E "s/CREATE SCHEMA public;//g" \ | ||
| | sed -E "s/SET search_path = public/SET search_path = ${SCHEMA}/g" |
Comment on lines
+46
to
+55
| // Cast back to the schema-agnostic SupabaseClient type: a runtime schema | ||
| // string widens the inferred literal ('public') and would otherwise not | ||
| // match the module-level type. The client is identical at runtime. | ||
| supabase = createClient(url, key, { | ||
| db: { schema }, | ||
| auth: { | ||
| persistSession: false, | ||
| autoRefreshToken: false, | ||
| }, | ||
| }); | ||
| }) as SupabaseClient; |
Doc-review found the three new env vars (SUPABASE_DB_SCHEMA, ENV_FILE, VCON_INSTANCE_LABEL) were in .env.example + the new guide but missing from the canonical reference spots. - installation.md: add the 3 vars to the Environment Variables Reference table - configuration.md: new "Multi-Group / Schema Isolation" subsection after RLS - README.md: "Isolating groups" pointer after the backend note - getting-started.md: bump stale "Last Updated" April -> June 2026 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The multi-group feature introduces .env.<group> files (credentials) and scripts/bootstrap-schema.sh writes sql/ dumps. Ignore both; keep .env.example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines
+57
to
+61
| # Reject reserved / unsafe schema names. | ||
| if [[ "$SCHEMA" =~ [^a-zA-Z0-9_] || "$SCHEMA" == "public" || "$SCHEMA" == "extensions" || "$SCHEMA" == "auth" ]]; then | ||
| echo "Refusing schema name '$SCHEMA' (use a simple identifier; not public/extensions/auth)." >&2 | ||
| exit 1 | ||
| fi |
Comment on lines
+80
to
+83
| printf '%s\n' "$DUMP" \ | ||
| | sed -E "s/\bpublic\./${SCHEMA}./g" \ | ||
| | sed -E "s/CREATE SCHEMA public;//g" \ | ||
| | sed -E "s/SET search_path = public/SET search_path = ${SCHEMA}/g" |
Comment on lines
+49
to
+52
| group="${base#.env.}" | ||
| # Read just the two vars we care about without polluting the shell env. | ||
| url="$(grep -E '^\s*SUPABASE_URL=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' | xargs || true)" | ||
| schema="$(grep -E '^\s*SUPABASE_DB_SCHEMA=' "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' | xargs || true)" |
…on types, grants Three bugs found provisioning a schema group on macOS, plus one gap: - BSD sed has no \b word boundary, so the public.->schema. rewrite silently matched nothing and objects landed in the wrong schema. Rewrite now uses perl -pe (ships with macOS), with a post-write guardrail that fails loudly if the output still lacks <schema>.vcons. - Host pg_dump older than the server (Homebrew 14 vs Supabase local 17) aborts. The script now compares majors and falls back to running pg_dump/psql inside the supabase_db_* container publishing the DB port; --db-container overrides detection. - Extension-owned names installed in public (pgvector vector/halfvec/ sparsevec types, opclasses, functions) were rewritten to <schema>.vector, which fails since pg_dump skips extension members. They are now excluded via a pg_depend/pg_extension catalog query, seeded with pgvector names as a floor. - The dump uses --no-privileges, so the generated SQL now ends with GRANT USAGE + table/sequence/function grants and default privileges for anon, authenticated, service_role (otherwise PostgREST returns permission denied for the new schema). Verified against the local stack: generated output is byte-identical to the hand-built sql/bootstrap-personal.sql (modulo \restrict token and the new grants), and --apply creates 12 tables, 1 matview, 44 functions with working grants and vector(384) columns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Lets each vCon group run as its own MCP instance, isolated either by separate Supabase project (physical) or separate Postgres schema in a shared project (logical) — selectable per instance via env. The caller picks a group by choosing which named MCP server to call.
An instance is now fully described by
{ SUPABASE_URL, key, SUPABASE_DB_SCHEMA }:SUPABASE_URL→ project isolationSUPABASE_DB_SCHEMA→ schema isolationChanges
src/db/client.ts— honorSUPABASE_DB_SCHEMA(defaultpublic), passdb.schematocreateClientso.from()/.rpc()route to the group's schema; log the schema.src/index.ts—ENV_FILE-awaredotenv.configso each instance loads.env.<group>; instance label + schema in startup banner.src/api/routes/schema.ts— surfaceinstance+schemainGET /health.package.json—start/start:groupscripts.scripts/start-group.sh— launch an instance for a named group.scripts/bootstrap-schema.sh— clonepublicinto a new schema (writes reviewable SQL, applies with--apply).scripts/migrate-all-groups.sh— fan out migrations across every.env.<group>(the main ongoing cost of isolation).docs/guide/multi-supabase-isolation.md+ sidebar entry; new env vars documented in.env.example.Why not separate Postgres databases
Supabase's PostgREST layer (which
supabase-jsrides on) serves only the one defaultpostgresdatabase, so a second database is unreachable without droppingsupabase-jsfor a rawpgconnection. Schema isolation is the Postgres-native option that fits this stack.Security note
In schema-isolation mode a single project service-role key bypasses RLS and can read every exposed schema — so with a shared service-role key it's an organizational boundary, not a hard one. A real boundary needs per-schema Postgres roles (documented as a follow-up in the guide). Project isolation is a security boundary by default.
Verification
npm run buildgreen.vitest run), 5 Mongo tests skipped.VCON_INSTANCE_LABEL+SUPABASE_DB_SCHEMA;GET /healthcorrectly reportsinstanceandschema,database: connected.🤖 Generated with Claude Code