Guide · DevOps
MCP server preview environments
A preview environment deploys each pull request to a unique URL so reviewers can test against a live server before merging. For MCP servers this matters more than for REST APIs: you can't fully validate an MCP server's behavior from code review alone — you need to connect an actual AI client, run tool calls, and verify the initialize → tools/list → callTool sequence works end-to-end on the real cloud infrastructure. Preview environments give you that window for every PR without touching production.
TL;DR
Deploy each PR to a unique URL like https://pr-123.preview.yourdomain.com/mcp using Railway PR environments or Fly.io machines. Add an AliveMCP monitor for the preview URL as the CI quality gate — the PR check only passes if the external MCP probe passes. Point a test Claude Desktop profile or Cursor workspace at the preview URL to manually test new tools. Destroy the environment when the PR is merged or closed. Use a shared preview database (with the PR number as a namespace prefix) rather than provisioning a new database per PR.
What preview environments catch that CI tests miss
| Issue class | Caught by unit tests | Caught by integration tests | Caught by preview environment |
|---|---|---|---|
| Logic bug in tool handler | Yes | Yes | Yes |
| Wrong tool schema (inputSchema mismatch) | No | Yes (snapshot) | Yes |
| Missing environment variable in production config | No | No | Yes |
| Database migration not applied before deploy | No | No | Yes |
| TLS certificate issue on real domain | No | No | Yes |
| CORS misconfiguration blocking AI client | No | No | Yes |
| Tool behavior feels wrong to the AI (wrong description) | No | No | Yes (manual test) |
The bottom row is the most valuable: acceptance testing (does the tool do what its description promises?) requires connecting an actual LLM client and verifying the interaction, not just asserting on return values. Preview environments give reviewers that ability.
Railway PR environments
Railway supports ephemeral PR environments natively. Enable them in your Railway project settings under "Environments" → "Enable PR environments." Railway creates a new environment for each PR, deploying the branch's code and creating a unique URL.
# In your CI workflow — Railway handles the deploy automatically
# You just need to run post-deploy checks
name: Preview environment checks
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
preview-checks:
runs-on: ubuntu-latest
steps:
- name: Wait for Railway preview deploy
id: railway
run: |
# Railway sets the preview URL in the deployment webhook
# Or fetch it via Railway API
PR_NUM=${{ github.event.pull_request.number }}
PREVIEW_URL="https://pr-${PR_NUM}.preview.yourdomain.com"
echo "preview_url=${PREVIEW_URL}" >> $GITHUB_OUTPUT
# Poll until the preview is up (Railway deploys take 30–90 seconds)
for i in $(seq 1 30); do
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "${PREVIEW_URL}/health" || echo "000")
[ "$STATUS" = "200" ] && { echo "Preview up"; break; }
sleep 5
done
- name: MCP protocol smoke test
run: |
PREVIEW_URL="${{ steps.railway.outputs.preview_url }}"
# Test the MCP initialize handshake
curl -sf -X POST "${PREVIEW_URL}/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"preview-check","version":"1"}}}' \
| grep -q protocolVersion || { echo "MCP initialize failed on preview"; exit 1; }
# Verify expected tools are registered
TOOLS=$(curl -sf -X POST "${PREVIEW_URL}/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| jq -r '[.result.tools[].name] | sort | @json')
echo "Tools on preview: $TOOLS"
- name: Comment preview URL on PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Preview environment ready\n\n**MCP endpoint:** \`${{ steps.railway.outputs.preview_url }}/mcp\`\n\nTo test in Claude Desktop, add this to your config:\n\`\`\`json\n{\n "mcpServers": {\n "preview": {\n "url": "${{ steps.railway.outputs.preview_url }}/mcp"\n }\n }\n}\n\`\`\``
});
Fly.io machine-per-PR
Fly.io's flyctl can create and destroy individual machines for each PR. This approach is more manual than Railway's built-in PR environments but gives you full control over the machine configuration:
name: Fly.io preview environment
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
deploy-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- name: Deploy preview machine
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
run: |
PR_NUM="${{ github.event.pull_request.number }}"
APP_NAME="myapp-pr-${PR_NUM}"
REGION="iad"
# Create the app if it doesn't exist
flyctl apps create "${APP_NAME}" --org personal 2>/dev/null || true
# Deploy the current branch to the preview app
flyctl deploy \
--app "${APP_NAME}" \
--region "${REGION}" \
--env "NODE_ENV=preview" \
--env "PR_NUMBER=${PR_NUM}" \
--env "DATABASE_URL=${{ secrets.PREVIEW_DATABASE_URL }}" \
--wait-timeout 120
PREVIEW_URL="https://${APP_NAME}.fly.dev"
echo "PREVIEW_URL=${PREVIEW_URL}" >> $GITHUB_ENV
- name: MCP protocol probe
run: |
for i in $(seq 1 20); do
curl -sf -X POST "${PREVIEW_URL}/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}' \
| grep -q protocolVersion && { echo "Preview probe passed"; break; }
sleep 5
done
on_close:
pull_request:
types: [closed]
jobs:
teardown:
runs-on: ubuntu-latest
steps:
- uses: superfly/flyctl-actions/setup-flyctl@master
- name: Delete preview app
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
run: |
APP_NAME="myapp-pr-${{ github.event.pull_request.number }}"
flyctl apps destroy "${APP_NAME}" --yes 2>/dev/null || true
Database strategy for preview environments
Provisioning a new PostgreSQL database per PR is expensive and slow. Instead, use a single shared preview database with per-PR schema namespacing:
// In your MCP server startup, when NODE_ENV=preview
const prNumber = process.env.PR_NUMBER;
const schemaName = prNumber ? `pr_${prNumber}` : 'public';
// Create schema for this PR if it doesn't exist
await db.execute(`CREATE SCHEMA IF NOT EXISTS pr_${prNumber}`);
await db.execute(`SET search_path TO pr_${prNumber}, public`);
// Run migrations scoped to this PR's schema
await runMigrations(db, { schema: schemaName });
On PR close, drop the schema:
await db.execute(`DROP SCHEMA IF EXISTS pr_${prNumber} CASCADE`);
This approach uses one database connection pool for all preview environments, with PostgreSQL schemas providing isolation. Each PR gets a clean schema with its migrations applied, and the data is destroyed on PR close. Never point preview environments at the production database.
AliveMCP probe-per-PR as a required CI check
Add an AliveMCP monitor for each preview environment and use the probe result as a required CI check. The AliveMCP API lets you create and delete monitors programmatically:
- name: Create AliveMCP monitor for preview
id: alivemcp
run: |
MONITOR_RESPONSE=$(curl -sf -X POST https://alivemcp.com/api/monitors \
-H 'Authorization: Bearer ${{ secrets.ALIVEMCP_API_KEY }}' \
-H 'Content-Type: application/json' \
-d "{\"url\": \"${PREVIEW_URL}/mcp\", \"name\": \"PR-${{ github.event.pull_request.number }} preview\", \"interval_seconds\": 60}")
MONITOR_ID=$(echo "$MONITOR_RESPONSE" | jq -r '.id')
echo "monitor_id=${MONITOR_ID}" >> $GITHUB_OUTPUT
- name: Wait for AliveMCP probe to pass
run: |
MONITOR_ID="${{ steps.alivemcp.outputs.monitor_id }}"
for i in $(seq 1 10); do
STATUS=$(curl -sf "https://alivemcp.com/api/monitors/${MONITOR_ID}" \
-H 'Authorization: Bearer ${{ secrets.ALIVEMCP_API_KEY }}' \
| jq -r '.last_result.status')
[ "$STATUS" = "up" ] && { echo "AliveMCP probe passed"; break; }
[ "$i" = "10" ] && { echo "AliveMCP probe did not pass after 5 minutes"; exit 1; }
sleep 30
done
Add the AliveMCP check as a required status check in your repository's branch protection settings. A PR can only be merged if AliveMCP confirms the preview environment is running a healthy MCP server — not just an HTTP server that returns 200.
Configuring AI clients against the preview URL
The PR comment from the CI workflow above includes copy-paste configuration for Claude Desktop. For Cursor, add a .cursor/mcp.json at the repo root with an input variable for the preview URL:
{
"mcpServers": {
"preview": {
"url": "https://pr-123.preview.yourdomain.com/mcp"
}
}
}
Reviewers checking out the branch can update this URL to their PR number, reload the MCP connection, and test the new tools interactively with the AI client. This is the closest thing to "acceptance testing" you can do before merging — testing not just that the server starts, but that the tools behave correctly when an AI assistant actually uses them.
Cost management
Preview environments incur real hosting costs. Common patterns to keep costs down:
- Scale to zero when idle — Fly.io machines can auto-stop after inactivity. Railway free-tier preview environments sleep automatically.
- Maximum lifetime — Add a GitHub Actions scheduled job that deletes any preview apps older than 7 days, even if the PR is still open.
- Only deploy on label — Gate preview deployment on a
previewlabel so routine documentation PRs don't spin up servers. - Minimal config — Preview environments should use the smallest available instance size. They're for functional testing, not performance testing.
on:
pull_request:
types: [labeled]
jobs:
deploy-preview:
if: github.event.label.name == 'preview'
# ... rest of deploy job
Related pages
FAQ
How do I handle secrets in preview environments?
Never use production secrets in preview environments. Create a separate set of credentials for preview: a read-only API key for external services, a preview-only database user, and a test environment API key for third-party integrations. Store these as GitHub Actions secrets with a PREVIEW_ prefix (e.g., PREVIEW_DATABASE_URL). The preview server should not have access to production data — it's for testing behavior, not for handling real user data.
Can I reuse preview environments across pushes to the same PR?
Yes. The CI job uses the PR number to name the environment (pr-123.preview.yourdomain.com), so every push to the same PR redeploys to the same URL. Railway does this automatically with PR environments. With Fly.io, flyctl deploy --app myapp-pr-123 replaces the existing machine in-place. The reviewer's bookmarked preview URL keeps working throughout the PR lifecycle.
Should preview environments use the same transport as production?
Yes. If your production server uses Streamable HTTP transport, use Streamable HTTP in the preview. If production uses SSE, use SSE. The preview environment should be as close to production as possible — using a different transport in preview misses a whole class of transport-specific bugs (SSE framing, CORS, connection handling) that won't appear until the PR merges to production.
How do I run acceptance tests against the preview environment?
Run the tests with the preview URL as the target. If your acceptance tests use the MCP SDK Client directly (not InMemoryTransport), pass the preview URL to the client: new Client(...).connect(new SSEClientTransport(new URL(previewUrl))). This tests the full network path — DNS, TLS, HTTP server, protocol stack — against the real deployed preview, not an in-process transport. Reserve in-process InMemoryTransport tests for unit/integration test suites that run against the code, not the infrastructure.
What if the preview environment is too slow for review feedback?
Optimize the deploy time, not the approach. Common bottlenecks: Docker image build time (use layer caching, base images, npm ci before COPY src), database migration time (skip seeding large datasets in preview — use minimal fixtures), and cold-start time (pre-build the image in CI before triggering the platform deploy). Target under 90 seconds from push to a live preview URL. If you can't hit that, the issue is usually the Docker build cache not being warm in CI.