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
- No environment-tier credential separation: Single
.envfile used for all environments, noNODE_ENVguard in config loading, no validation that test credentials are scoped to test infrastructure - Missing production API call protection: Test suite lacks HTTP request interception; no assertion that outbound calls during tests are to expected, test-safe endpoints
- Inline client instantiation in tool handlers: Tool handlers that construct their own API clients rather than accepting injected dependencies — prevents test stubbing without module mocking
- Production credentials in CI environment: CI configuration files (
.github/workflows/,.gitlab-ci.yml) that reference production secret names rather than test-specific secrets - No isolated test database: Test configuration pointing to a production database URL or using a schema-prefix pattern rather than a separate database instance
— SkillAudit scans for these patterns automatically. Scan your MCP server.