Topic: test isolation security

MCP server test isolation security — production data leakage, test credential separation, environment variable isolation, test environment hardening

Test environments are a common entry point for production credential leakage in MCP servers. Shared .env files, copy-pasted production tokens in test configs, and MCP servers that call production APIs in integration tests create real exposure. Five patterns for strict test isolation: environment-specific credential configuration, production API call detection, test-only service stubs, CI environment hardening, and test data isolation.

1. Environment-specific credential configuration

The most common source of production credential leakage in MCP server tests is a .env file that was copied from a developer's local setup — or from a colleague — and committed to the repository or shared via a team channel. That .env file contains real credentials. When the test suite loads it, production credentials are now in the test environment.

The fix is to establish a hard separation between credential files by environment tier, enforced at the configuration loading layer. The MCP server's config module should refuse to load if the credential tier does not match the runtime environment. A test runner that accidentally points at production credentials should fail loudly, not silently proceed.

// config.ts — enforces environment-tier credential separation
import { config } from 'dotenv'
import { resolve } from 'path'

type EnvironmentTier = 'test' | 'development' | 'staging' | 'production'

interface CredentialConfig {
  githubToken: string
  databaseUrl: string
  apiKey: string
  tier: EnvironmentTier
}

function detectTier(): EnvironmentTier {
  const env = process.env.NODE_ENV
  if (env === 'test') return 'test'
  if (env === 'development') return 'development'
  if (env === 'staging') return 'staging'
  if (env === 'production') return 'production'
  throw new Error(`Unknown NODE_ENV: '${env}'. Must be one of: test, development, staging, production`)
}

function loadEnvForTier(tier: EnvironmentTier): void {
  const envFiles: Record = {
    test:        ['.env.test', '.env.test.local'],
    development: ['.env.development', '.env.development.local'],
    staging:     ['.env.staging'],
    production:  ['.env.production']
  }
  for (const file of envFiles[tier]) {
    config({ path: resolve(process.cwd(), file), override: false })
  }
}

function validateCredentialTier(config: CredentialConfig): void {
  const tier = config.tier

  // Hard guard: if running tests, credentials must not have production write access
  if (tier === 'test') {
    if (config.databaseUrl.includes('prod') || config.databaseUrl.includes('production')) {
      throw new Error(
        'SECURITY: Test suite is configured with a production database URL. ' +
        'Set DATABASE_URL in .env.test to a test-only database instance.'
      )
    }
    if (config.githubToken && !config.githubToken.startsWith('ghp_test_')) {
      console.warn(
        'WARNING: GitHub token in test environment does not appear to be a test token. ' +
        'Verify that GITHUB_TOKEN in .env.test is scoped to test repositories only.'
      )
    }
  }
}

export function loadConfig(): CredentialConfig {
  const tier = detectTier()
  loadEnvForTier(tier)

  const cfg: CredentialConfig = {
    githubToken: process.env.GITHUB_TOKEN ?? '',
    databaseUrl: process.env.DATABASE_URL ?? '',
    apiKey: process.env.API_KEY ?? '',
    tier
  }

  validateCredentialTier(cfg)
  return cfg
}

2. Production API call detection

Even with the correct .env.test configuration, an MCP server integration test may inadvertently call a production API — through a hardcoded URL in the server code, a misconfigured base URL, or a tool handler that resolves its endpoint from a runtime variable that was not overridden in the test setup. These calls go to production infrastructure with test credentials (at best) or leaked production credentials (at worst).

The detection strategy is to intercept all outbound HTTP requests during the test suite and fail the test immediately if a request targets a production hostname that is not in an explicit allowlist. Using nock or similar request interception, the test harness registers an assertion handler that blocks unexpected production hostnames before the request is sent.

import nock from 'nock'

// Production hostnames that should NEVER be called in tests
const PRODUCTION_HOSTNAMES = [
  'api.github.com',
  'hooks.slack.com',
  'api.stripe.com',
  'sentry.io',
  // Add your production API hostnames here
]

// Test-safe endpoints that tests may call (test environment equivalents)
const TEST_SAFE_HOSTNAMES = [
  'api.github-test.internal',
  'localhost',
  '127.0.0.1',
]

export function setupProductionApiGuard(): void {
  // Intercept all unregistered HTTP requests and check hostname
  nock.emitter.on('no match', (req) => {
    const hostname = req.hostname ?? req.host ?? ''

    if (PRODUCTION_HOSTNAMES.some(prod => hostname.includes(prod))) {
      const message =
        `SECURITY TEST FAILURE: Test attempted a real HTTP request to production hostname '${hostname}'.\n` +
        `URL: ${req.path}\n` +
        `This indicates a production API call in the test suite.\n` +
        `Either: (1) stub this request with nock, or (2) add the test-environment URL to the server config.`

      // Fail loudly — production API calls in tests are blocking failures
      console.error(message)
      throw new Error(message)
    }
  })
}

// Setup in global test setup file (jest.config.ts → globalSetup)
export function globalTestSetup(): void {
  // Block ALL real HTTP requests by default in test suite
  // Tests must explicitly register their expected requests with nock
  nock.disableNetConnect()
  nock.enableNetConnect(/localhost|127\.0\.0\.1/)
  setupProductionApiGuard()
}

// Example: registering expected API calls in a specific test
describe('GitHub tool handler', () => {
  beforeEach(() => {
    // Explicitly declare what outbound calls this test expects
    nock('https://api.github-test.internal')
      .get('/repos/test-org/test-repo')
      .reply(200, { name: 'test-repo', private: false })
  })

  afterEach(() => {
    // Assert all expected calls were made and no unexpected calls occurred
    expect(nock.isDone()).toBe(true)
    nock.cleanAll()
  })
})

3. Test-only service stubs

MCP tool handlers that instantiate their own API clients inline — creating a new GitHubClient() or fetch() call directly inside the handler body — cannot have those clients replaced in tests. The only way to test those handlers without hitting real APIs is to mock the entire module, which is fragile and hides integration bugs. The correct pattern is dependency injection: pass API clients as constructor arguments or function parameters, so tests can provide controlled stub implementations without module mocking.

// ANTI-PATTERN: handler instantiates its own client inline
// Cannot be tested without real GitHub API access
async function listRepositoriesAntiPattern(org: string) {
  const client = new GitHubClient(process.env.GITHUB_TOKEN)  // hardcoded dependency
  return client.listRepos(org)
}

// INJECTION PATTERN: client passed as parameter
interface GitHubClientInterface {
  listRepos(org: string): Promise
  readFile(repo: string, path: string, ref: string): Promise
}

// Production implementation
class GitHubClient implements GitHubClientInterface {
  constructor(private token: string) {}

  async listRepos(org: string): Promise {
    const res = await fetch(`https://api.github.com/orgs/${org}/repos`, {
      headers: { Authorization: `token ${this.token}` }
    })
    return res.json()
  }

  async readFile(repo: string, path: string, ref: string): Promise {
    const res = await fetch(
      `https://api.github.com/repos/${repo}/contents/${path}?ref=${ref}`,
      { headers: { Authorization: `token ${this.token}` } }
    )
    const data = await res.json()
    return Buffer.from(data.content, 'base64').toString('utf-8')
  }
}

// Test stub — records calls for assertion, never hits real API
class GitHubClientStub implements GitHubClientInterface {
  readonly calls: Array<{ method: string; args: unknown[] }> = []
  private responses = new Map()

  setResponse(key: string, response: unknown): void {
    this.responses.set(key, response)
  }

  async listRepos(org: string): Promise {
    this.calls.push({ method: 'listRepos', args: [org] })
    return (this.responses.get(`listRepos:${org}`) ?? []) as Repository[]
  }

  async readFile(repo: string, path: string, ref: string): Promise {
    this.calls.push({ method: 'readFile', args: [repo, path, ref] })
    return (this.responses.get(`readFile:${repo}:${path}`) ?? '') as string
  }
}

// MCP tool handler using injected client
class GitHubToolHandler {
  constructor(private github: GitHubClientInterface) {}

  async handleListRepos(org: string) {
    const repos = await this.github.listRepos(org)
    return { content: [{ type: 'text', text: repos.map(r => r.name).join('\n') }] }
  }
}

// Test — no real API calls, stub records for assertion
describe('GitHubToolHandler', () => {
  it('returns repo names from the GitHub client', async () => {
    const stub = new GitHubClientStub()
    stub.setResponse('listRepos:my-org', [{ name: 'repo-a' }, { name: 'repo-b' }])
    const handler = new GitHubToolHandler(stub)
    const result = await handler.handleListRepos('my-org')
    expect(result.content[0].text).toBe('repo-a\nrepo-b')
    expect(stub.calls).toHaveLength(1)
    expect(stub.calls[0]).toEqual({ method: 'listRepos', args: ['my-org'] })
  })
})

4. CI environment hardening

CI pipelines have their own credential leakage risks specific to MCP server development. CI environment variables that are scoped too broadly (visible to all jobs in a pipeline rather than scoped to the specific job that needs them), CI secrets that are never rotated (the same test credential has been in the CI environment for two years), and CI pipelines that inherit production credentials through environment inheritance — all of these create exposure that is not covered by local development credential hygiene.

# GitHub Actions workflow — CI environment hardening for MCP server tests
# Save as: .github/workflows/test.yml

name: MCP Server Tests

on:
  pull_request:
  push:
    branches: [main]

jobs:
  unit-tests:
    name: Unit tests (no credentials)
    runs-on: ubuntu-latest
    # Unit tests run with no credentials at all
    # If unit tests require a credential, that is a test isolation failure
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:unit
        env:
          NODE_ENV: test
          # No credentials — unit tests must use stubs

  integration-tests:
    name: Integration tests (test credentials, scoped)
    runs-on: ubuntu-latest
    # Only runs on PRs from trusted authors, not external forks
    if: github.event.pull_request.head.repo.full_name == github.repository
    environment: test  # GitHub environment with test-only secrets
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:integration
        env:
          NODE_ENV: test
          # Credentials scoped to this job only, from the 'test' environment
          # These are rotatable test credentials, never production credentials
          GITHUB_TEST_TOKEN: ${{ secrets.GITHUB_TEST_TOKEN }}
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
          # NEVER: GITHUB_TOKEN (production), AWS_ACCESS_KEY_ID (production), etc.

  # Validate that no production secrets are referenced in test configuration
  audit-test-config:
    name: Audit test configuration for production credential references
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check for production credential patterns in test files
        run: |
          # Fail if test files reference production database URLs or token patterns
          if grep -r "DATABASE_URL" test/ --include="*.ts" | grep -v "process.env\|TEST_DATABASE_URL"; then
            echo "ERROR: Hardcoded DATABASE_URL found in test files"
            exit 1
          fi
          echo "Test configuration audit passed"

5. Test data isolation

Running integration tests against a "test schema" on the production database instance is not test isolation — it is a live connection to production infrastructure with a different table prefix. A bug in the test teardown code, a misconfigured schema in a migration, or a test that writes to the wrong schema can corrupt production data. Real test data isolation requires a completely separate database instance, seeded from version-controlled fixtures, with no production data, and with teardown that resets to a known state after each test run.

import { Client } from 'pg'

interface TestDatabaseManager {
  setup(): Promise
  teardown(): Promise
  seed(fixtures: Record): Promise
}

class IsolatedTestDatabase implements TestDatabaseManager {
  private client: Client | null = null
  private readonly TEST_DB_GUARD = 'test_isolation_marker'

  constructor(private databaseUrl: string) {
    // Verify at construction time that this is a test database URL
    if (!databaseUrl.includes('test') && !databaseUrl.includes('localhost')) {
      throw new Error(
        `SECURITY: IsolatedTestDatabase initialized with a URL that does not ` +
        `appear to be a test database: '${databaseUrl}'.\n` +
        `Test database URLs must contain 'test' or point to localhost.`
      )
    }
  }

  async setup(): Promise {
    this.client = new Client({ connectionString: this.databaseUrl })
    await this.client.connect()

    // Verify this is actually a test database by checking for the guard marker
    // This guard is inserted by the test database provisioning script
    const result = await this.client.query(
      `SELECT 1 FROM information_schema.tables WHERE table_name = $1`,
      [this.TEST_DB_GUARD]
    )
    if (result.rowCount === 0) {
      throw new Error(
        `SECURITY: Test database at '${this.databaseUrl}' does not have the ` +
        `test isolation marker table '${this.TEST_DB_GUARD}'.\n` +
        `This indicates the database may not be a provisioned test instance.`
      )
    }
  }

  async seed(fixtures: Record): Promise {
    if (!this.client) throw new Error('Database not set up — call setup() first')

    await this.client.query('BEGIN')
    for (const [table, rows] of Object.entries(fixtures)) {
      for (const row of rows) {
        const columns = Object.keys(row as object)
        const values = Object.values(row as object)
        const placeholders = values.map((_, i) => `$${i + 1}`).join(', ')
        await this.client.query(
          `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`,
          values
        )
      }
    }
    await this.client.query('COMMIT')
  }

  async teardown(): Promise {
    if (!this.client) return
    // Truncate all non-system tables to reset to clean state
    const tables = await this.client.query<{ tablename: string }>(
      `SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename != $1`,
      [this.TEST_DB_GUARD]
    )
    for (const { tablename } of tables.rows) {
      await this.client.query(`TRUNCATE TABLE "${tablename}" CASCADE`)
    }
    await this.client.end()
    this.client = null
  }
}

// Usage in test suite
const testDb = new IsolatedTestDatabase(process.env.DATABASE_URL!)

beforeAll(async () => {
  await testDb.setup()
})

beforeEach(async () => {
  await testDb.seed({
    users: [{ id: 'test-user-1', email: 'test@example.test', role: 'developer' }],
    repositories: [{ id: 'test-repo-1', name: 'test-repo', owner: 'test-user-1' }]
  })
})

afterEach(async () => {
  await testDb.teardown()
})

SkillAudit checks for test isolation security

SkillAudit scans for these patterns automatically. Scan your MCP server.