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 initializetools/listcallTool 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 classCaught by unit testsCaught by integration testsCaught by preview environment
Logic bug in tool handlerYesYesYes
Wrong tool schema (inputSchema mismatch)NoYes (snapshot)Yes
Missing environment variable in production configNoNoYes
Database migration not applied before deployNoNoYes
TLS certificate issue on real domainNoNoYes
CORS misconfiguration blocking AI clientNoNoYes
Tool behavior feels wrong to the AI (wrong description)NoNoYes (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:

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.