12 KiB
Deployment
For a start-to-finish private-alpha self-host walkthrough, read Self-Host Vault MCP first. This page is the lower-level runtime, environment, and platform reference.
Runtime
The server is a Node HTTP service. It can run from npm run start after npm run build, as a Vercel Express Function through api/index.ts, or from the included Dockerfile on a container host.
Required production environment:
HOST=0.0.0.0
PORT=3333
PUBLIC_BASE_URL=https://vault-mcp.example.com
DATABASE_URL=postgres://user:password@host:5432/vault_mcp
MCP_SYNC_TOKEN=long-random-sync-token
ALLOWED_ORIGINS=https://chatgpt.com,https://claude.ai
OAUTH_ISSUER=https://auth.example.com
OAUTH_AUDIENCE=https://vault-mcp.example.com/mcp
OAUTH_AUTHORIZATION_SERVER=https://auth.example.com
OAUTH_JWKS_URL=https://auth.example.com/.well-known/jwks.json
OAUTH_SCOPES=vault:read
MCP_ACCESS_TOKEN is for local development and one-off MCP Inspector testing. Production should prefer OAuth JWT validation through OAUTH_* variables.
Hosted write proposals are private-alpha and off by default. To expose only the proposal queue tools, add:
MCP_WRITE_PROPOSALS_ENABLED=true
OAUTH_SCOPES="vault:read vault:write"
Both gates are required for OAuth clients. Reauthorize existing ChatGPT/Claude connections after changing scopes. The server still cannot edit local files; the Obsidian plugin reviews and applies each proposal after live hash, backup, and audit checks.
Hosted local filesystem delegation is a separate private-alpha gate. Enable it only after the Obsidian plugin and localhost sidecar are installed:
MCP_REMOTE_LOCAL_FS_ENABLED=true
MCP_REMOTE_LOCAL_FS_REQUEST_TTL_SECONDS=45
MCP_REMOTE_LOCAL_FS_WAIT_SECONDS=25
MCP_REMOTE_LOCAL_FS_AGENT_FRESH_SECONDS=10
OAUTH_SCOPES="vault:read local:access"
Add vault:write to the same scope list only when proposal tools are also
enabled. Existing ChatGPT connections must be reauthorized after adding
local:access. The hosted bridge does not grant permissions by itself: the
matching Obsidian installation must explicitly enable the bridge and its
localhost sidecar policy still decides whether the call is off, read, write,
or god mode.
For a self-hosted OAuth flow on the connector itself, set the OAuth issuer and authorization server to the public service URL and use an HMAC signing secret plus a private authorization password:
PUBLIC_BASE_URL=https://vault-mcp.example.com
OAUTH_ISSUER=https://vault-mcp.example.com
OAUTH_AUDIENCE=https://vault-mcp.example.com/mcp
OAUTH_AUTHORIZATION_SERVER=https://vault-mcp.example.com
OAUTH_JWT_SECRET=long-random-jwt-secret
OAUTH_AUTH_PASSWORD=private-human-authorization-password
OAUTH_SCOPES=vault:read
This enables:
GET /.well-known/oauth-authorization-serverGET /.well-known/oauth-authorization-server/mcpPOST /oauth/registerfor dynamic client registrationGET/POST /oauth/authorizefor password-gated authorization-code + PKCEPOST /oauth/tokenfor authorization-code and refresh-token grants
Vercel
Vercel does not deploy Docker images directly. For Vercel, use the api/index.ts Express entrypoint and vercel.json; the Express app becomes a single Vercel Function.
Required steps:
vercel login
vercel link
vercel pull --yes --environment=production
vercel env add PUBLIC_BASE_URL production
vercel env add DATABASE_URL production
vercel env add MCP_SYNC_TOKEN production
vercel env add OAUTH_ISSUER production
vercel env add OAUTH_AUDIENCE production
vercel env add OAUTH_AUTHORIZATION_SERVER production
vercel env add OAUTH_JWKS_URL production
vercel env add OAUTH_SCOPES production
# Optional private-alpha proposal queue:
vercel env add MCP_WRITE_PROPOSALS_ENABLED production
# Optional private-alpha hosted desktop bridge:
vercel env add MCP_REMOTE_LOCAL_FS_ENABLED production
vercel env add MCP_REMOTE_LOCAL_FS_REQUEST_TTL_SECONDS production
vercel env add MCP_REMOTE_LOCAL_FS_WAIT_SECONDS production
vercel env add MCP_REMOTE_LOCAL_FS_AGENT_FRESH_SECONDS production
vercel --prod
If using temporary static bearer auth for a private test deployment, set MCP_ACCESS_TOKEN too. Remove it when OAuth is the only intended production path.
Local vercel build requires project settings from vercel pull. In an unauthenticated checkout, Vercel CLI returns project_settings_required; authenticate and link first.
For setup after Vercel CLI login or after creating a Vercel token, use the bootstrap script:
# Optional for unattended runs. Omit if `vercel whoami` already works locally.
export VERCEL_TOKEN="..."
export VERCEL_TEAM_ID="team_mhftpUYWIR5oysxTjLnSLCol"
export VERCEL_PROJECT_NAME="vault-mcp-connector"
export DEPLOY_AUTH_MODE="oauth"
export PUBLIC_BASE_URL="https://vault-mcp.example.com"
export DATABASE_URL="postgres://user:password@host:5432/vault_mcp"
export MCP_SYNC_TOKEN="long-random-sync-token"
export ALLOWED_ORIGINS="https://chatgpt.com,https://claude.ai,https://chat.openai.com"
export OAUTH_ISSUER="https://auth.example.com"
export OAUTH_AUDIENCE="https://vault-mcp.example.com/mcp"
export OAUTH_AUTHORIZATION_SERVER="https://auth.example.com"
export OAUTH_JWKS_URL="https://auth.example.com/.well-known/jwks.json"
export OAUTH_SCOPES="vault:read"
npm run deploy:vercel:check
npm run deploy:vercel
The script links the local repo to the Vercel project, adds or updates production environment variables, pulls production settings, and runs vercel deploy --prod. Add -- --smoke or set RUN_REMOTE_SMOKE=1 when SMOKE_ACCESS_TOKEN or temporary MCP_ACCESS_TOKEN is available. If VERCEL_TOKEN is unset, it uses the authenticated local Vercel CLI session.
For a temporary private deployment before the OAuth provider is ready, use static-token mode:
export DEPLOY_AUTH_MODE="static"
export VERCEL_TOKEN="..."
export VERCEL_TEAM_ID="team_mhftpUYWIR5oysxTjLnSLCol"
export VERCEL_PROJECT_NAME="vault-mcp-connector"
export PUBLIC_BASE_URL="https://vault-mcp.example.com"
export DATABASE_URL="postgres://user:password@host:5432/vault_mcp"
export MCP_SYNC_TOKEN="long-random-sync-token"
export MCP_ACCESS_TOKEN="temporary-test-access-token"
export ALLOWED_ORIGINS="https://chatgpt.com,https://claude.ai,https://chat.openai.com"
npm run deploy:vercel:check
npm run deploy:vercel -- --smoke
Static-token mode is for MCP Inspector and remote smoke testing only. Switch back to DEPLOY_AUTH_MODE=oauth before ChatGPT/Claude production acceptance.
Self-hosted OAuth mode uses the same deploy script with OAUTH_JWT_SECRET and OAUTH_AUTH_PASSWORD instead of OAUTH_JWKS_URL:
export DEPLOY_AUTH_MODE="oauth"
export PUBLIC_BASE_URL="https://vault-mcp.example.com"
export OAUTH_ISSUER="$PUBLIC_BASE_URL"
export OAUTH_AUDIENCE="$PUBLIC_BASE_URL/mcp"
export OAUTH_AUTHORIZATION_SERVER="$PUBLIC_BASE_URL"
export OAUTH_JWT_SECRET="long-random-jwt-secret"
export OAUTH_AUTH_PASSWORD="private-human-authorization-password"
export OAUTH_SCOPES="vault:read"
Build
npm ci
npm run build
npm run start
Health And Observability
GET /healthz is the first operator check after deploy and after sync. It returns
safe runtime status without exposing secrets or database URLs:
okservice.nameservice.versionservice.public_base_urlservice.mcp_resource_urldocument_countvault_countgenerated_atlast_sync_atstorage.kindstorage.okstorage.migrationsfor Postgres
When storage is healthy, the endpoint returns HTTP 200. When Postgres is
unreachable or the schema cannot be queried, the endpoint returns HTTP 503 with
ok: false and a safe storage.error string.
Container Hosts
Use the Dockerfile for hosts that run containers directly, such as Fly.io, Render, Railway, a VPS, or any Kubernetes-like runtime:
Container build:
docker build -t vault-mcp-connector .
docker run --rm -p 3333:3333 --env-file .env vault-mcp-connector
Docker was not available in the current local verification environment.
Postgres
When DATABASE_URL is set, Vault MCP uses Postgres for derived vault index data,
OAuth client state, token replay protection, sync manifests, and write proposals.
Run migrations before the first deploy and before upgrades:
DATABASE_URL="postgres://user:password@host:5432/vault_mcp" npm run db:migrate
The command builds the server package, runs all pending migrations, and prints a JSON summary:
{
"ok": true,
"total_migrations": 1,
"applied_count": 1,
"applied": [
{
"id": "0001_initial_vault_mcp_schema",
"description": "Create the initial Vault MCP tables, indexes, and multi-vault columns."
}
],
"already_applied": [],
"pending": []
}
The server also runs the same migration runner during startup as a safety net,
but self-hosters should treat npm run db:migrate as the explicit deploy/upgrade
step. Applied migrations are tracked in vault_mcp_schema_migrations.
The current schema includes:
vault_documentsvault_index_metavault_sync_manifestsoauth_clientsoauth_token_useswrite_proposals- GIN full-text index on a generated
tsvector - supporting indexes for paths, vault scoping, token expiry, and write proposal status
The local indexer performs full replacement syncs:
MCP_SYNC_TOKEN="$MCP_SYNC_TOKEN" npm run index -- \
--vault "/path/to/disposable-test-vault" \
--vault-name "Disposable test vault" \
--public-base-url "$PUBLIC_BASE_URL" \
--server "$PUBLIC_BASE_URL"
For production syncs from the live vault, change --vault only after acceptance testing on the copied vault.
To verify the Postgres storage path before deploying, point the smoke test at a throwaway database. This performs a full replacement sync into vault_documents, so do not use a database that contains an index you need to preserve:
POSTGRES_SMOKE_DATABASE_URL="postgres://user:password@host:5432/vault_mcp_smoke" \
npm run smoke:postgres:fresh
smoke:postgres:fresh creates a temporary schema inside the configured database,
runs migrations from an empty state, syncs one fixture document, verifies
/healthz-equivalent store health, and drops the schema. It is the safest way to
prove fresh database boot without touching existing Vault MCP tables.
POSTGRES_SMOKE_DATABASE_URL="postgres://user:password@host:5432/vault_mcp_smoke" \
npm run smoke:postgres
The smoke test starts the server with DATABASE_URL set from POSTGRES_SMOKE_DATABASE_URL, runs the same migration runner, syncs the configured disposable test vault, and verifies tools/list, search, fetch, and guessed-ID denial through Postgres full-text search.
Remote Smoke Test
After deployment and env setup:
npm run build
SMOKE_BASE_URL="https://vault-mcp.example.com" \
MCP_ACCESS_TOKEN="temporary-test-access-token" \
MCP_SYNC_TOKEN="$MCP_SYNC_TOKEN" \
npm run smoke:remote
When using OAuth-only production, set SMOKE_ACCESS_TOKEN to an access token issued by the configured OAuth provider:
SMOKE_BASE_URL="https://vault-mcp.example.com" \
SMOKE_ACCESS_TOKEN="oauth-access-token" \
SMOKE_EXPECT_OAUTH=true \
npm run smoke:remote
For the self-hosted OAuth flow, verify dynamic client registration, PKCE authorization-code exchange, refresh tokens, and the MCP remote smoke in one pass:
SMOKE_BASE_URL="https://vault-mcp.example.com" \
SMOKE_OAUTH_PASSWORD="$OAUTH_AUTH_PASSWORD" \
MCP_SYNC_TOKEN="$MCP_SYNC_TOKEN" \
npm run smoke:oauth-flow
OAuth Protected Resource Metadata
The server exposes:
GET /.well-known/oauth-protected-resource
GET /.well-known/oauth-protected-resource/mcp
Unauthenticated MCP requests return 401 with WWW-Authenticate: Bearer resource_metadata="...".
The app can run in either of two OAuth modes:
- external provider mode: validate tokens from
OAUTH_JWKS_URL - self-hosted mode: issue and validate HMAC-signed access tokens with
OAUTH_JWT_SECRET, dynamic client registration, single-use PKCE authorization codes, and rotating refresh tokens
CORS and Origin Protection
ALLOWED_ORIGINS controls both DNS-rebinding/origin protection and CORS preflight responses. The server:
- accepts requests with no
Originheader, which is common for server-side MCP clients - returns
204to allowedOPTIONSpreflight requests - exposes
WWW-AuthenticateandMcp-Session-Idresponse headers - rejects disallowed origins with
403
Use comma-separated origins:
ALLOWED_ORIGINS=https://chatgpt.com,https://claude.ai,https://chat.openai.com