Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,24 @@ import * as dotenv from 'dotenv';
// of the initialize reply; clients that skip unparseable lines tolerate it,
// clients that don't see a corrupt stream and register zero tools with no
// error to read.
dotenv.config({ path: process.env.DOTENV_CONFIG_PATH, override: true, quiet: true });
//
// The console.log swap is NOT belt-and-braces — `quiet: true` alone is
// defeatable. dotenv resolves it as
// processEnv.DOTENV_CONFIG_QUIET || options.quiet (lib/main.js:248)
// and RE-READS it from processEnv after populate (lib/main.js:292),
// so the environment variable outranks the code option, and parseBoolean('false')
// is false. A stray DOTENV_CONFIG_QUIET=false — in the shell, in the MCP server's
// env block, or inside the .env file this very call is loading — silently puts the
// banner back on stdout. Routing console.log to stderr for the duration makes that
// unreachable however `quiet` resolves, and also catches anything else that decides
// to log during env loading. Restored in `finally` so nothing leaks past this line.
const __realLog = console.log;
console.log = (...args: unknown[]) => console.error(...args);
try {
dotenv.config({ path: process.env.DOTENV_CONFIG_PATH, override: true, quiet: true });
} finally {
console.log = __realLog;
}
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import * as schema from './schema.js';
Expand Down
45 changes: 45 additions & 0 deletions tests/env-loading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,51 @@ describe('env loading invariants', () => {
expect(src).toMatch(/dotenv\.config\([^)]*override:\s*true/);
});

it('db.ts must route console.log away from stdout around dotenv.config', () => {
// `quiet: true` alone is NOT sufficient. dotenv resolves quiet as
// processEnv.DOTENV_CONFIG_QUIET || options.quiet
// at lib/main.js:248, and RE-READS it after populate at :292 — so the env
// var wins over the code option, and parseBoolean('false') === false. A
// DOTENV_CONFIG_QUIET=false in the shell OR inside the .env file itself
// puts the banner back on stdout, which is the MCP JSON-RPC transport.
// The console.log swap makes that unreachable regardless of resolution.
const src = readFileSync(join(repoRoot, 'src/db.ts'), 'utf8');
expect(src).toMatch(/console\.log\s*=/);
});

it('the console.log swap survives DOTENV_CONFIG_QUIET=false', () => {
// Behavioural proof of the technique, independent of db.ts. Guards against
// a future dotenv release changing how quiet resolves.
const tmp = mkdtempSync(join(tmpdir(), 'env-quiet-'));
const envFile = join(tmp, 'noisy.env');
writeFileSync(envFile, 'QUIET_PROBE=1\n');

const prevVar = process.env.DOTENV_CONFIG_QUIET;
process.env.DOTENV_CONFIG_QUIET = 'false'; // hostile: defeats { quiet: true }

const realLog = console.log;
const stdoutLines: string[] = [];
console.log = (...args: unknown[]) => {
stdoutLines.push(args.join(' '));
};
let banner: string[] = [];
try {
// Confirm the hostile env var really does re-enable logging...
dotenv.config({ path: envFile, override: true, quiet: true });
banner = [...stdoutLines];
} finally {
console.log = realLog;
if (prevVar === undefined) delete process.env.DOTENV_CONFIG_QUIET;
else process.env.DOTENV_CONFIG_QUIET = prevVar;
}

// ...so the swap is load-bearing, not redundant. If this ever goes empty,
// dotenv changed its precedence and the swap may no longer be needed —
// verify before removing it.
expect(banner.length).toBeGreaterThan(0);
expect(banner.join('\n')).toMatch(/injected env/i);
});

it('db.ts must pass { quiet: true } — stdout is the MCP transport', () => {
// dotenv defaults quiet:false since 17.0.0 and logs its injection banner
// with console.log — i.e. onto stdout, which for the MCP server IS the
Expand Down