From db7a02ef59cd8040d75a131d6edbb5c731a51fbf Mon Sep 17 00:00:00 2001 From: effece Date: Tue, 18 Aug 2026 16:23:28 -0300 Subject: [PATCH] [db] fix: stop dotenv printing its banner onto the MCP stdout transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dotenv has defaulted quiet to FALSE since 17.0.0 and prints its injection banner with console.log — onto stdout, which for the MCP server is the JSON-RPC transport. Every server boot has emitted ◇ injected env (N) from .env // tip: ⌘ suppress logs { quiet: true } ahead of the initialize reply. Clients that skip unparseable lines tolerate it, which is why this stayed invisible; a stricter client sees a corrupt stream and registers zero tools with no error to read. It also contradicts the project's own convention: stderr only, stdout is the transport. dotenv.config() in src/db.ts and tests/integration/helpers.ts now passes quiet: true beside override: true. Both guards were updated first and watched fail on their real assertions (3 failures, not import errors) before the fix: db-guard.test.ts pins the two call sites to an identical shape so they cannot drift apart, env-loading.test.ts pins each property independently. src/import.ts and drizzle.config.ts keep bare `import 'dotenv/config'` and still print the banner — deliberate. Both are CLI entry points where stdout is not a transport, and rewriting import.ts risks the env-shadowing trap that src/db.ts's comment documents. Verified by a controlled stdio comparison. Both runs fail identically at the DB check (no .env present), so the banner is the only variable: unfixed, the server writes 1 line to stdout and it is the banner; fixed, it writes 0. The 30 dotenv-guard assertions pass, and tsc is clean on TypeScript 7.0.2. The 7 embed test failures seen locally are pre-existing and environmental — the bge-small model is not cached in a fresh clone; CI pre-caches it. --- src/db.ts | 9 ++++++++- tests/db-guard.test.ts | 4 ++-- tests/env-loading.test.ts | 9 +++++++++ tests/integration/helpers.ts | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/db.ts b/src/db.ts index 48acd86..c2f522f 100644 --- a/src/db.ts +++ b/src/db.ts @@ -8,7 +8,14 @@ import * as dotenv from 'dotenv'; // imports) already called `import 'dotenv/config'`, DATABASE_URL from ./.env // would otherwise shadow the explicit path here, silently routing all writes // to local Docker. Observed 2026-04-07 → 2026-04-13 (6 days of events drift). -dotenv.config({ path: process.env.DOTENV_CONFIG_PATH, override: true }); +// +// `quiet: true` is equally load-bearing: dotenv defaults it to FALSE since +// 17.0.0 and prints its injection banner via console.log — onto stdout, which +// for the MCP server is the JSON-RPC transport itself. The banner lands ahead +// 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 }); import { drizzle } from 'drizzle-orm/node-postgres'; import pg from 'pg'; import * as schema from './schema.js'; diff --git a/tests/db-guard.test.ts b/tests/db-guard.test.ts index 8605a0b..0ee9401 100644 --- a/tests/db-guard.test.ts +++ b/tests/db-guard.test.ts @@ -194,7 +194,7 @@ describe('helpers.ts resolves DATABASE_URL the same way src/db.ts does', () => { it('helpers.ts overrides from DOTENV_CONFIG_PATH, like db.ts', () => { expect(read('./integration/helpers.ts')).toMatch( - /dotenv\.config\(\{\s*path:\s*process\.env\.DOTENV_CONFIG_PATH,\s*override:\s*true\s*\}\)/, + /dotenv\.config\(\{\s*path:\s*process\.env\.DOTENV_CONFIG_PATH,\s*override:\s*true,\s*quiet:\s*true\s*\}\)/, ); }); @@ -209,7 +209,7 @@ describe('helpers.ts resolves DATABASE_URL the same way src/db.ts does', () => { // If db.ts ever changes its resolution, this pairing is stale and the two // pools can diverge again — fail here rather than against a live database. expect(read('../src/db.ts')).toMatch( - /dotenv\.config\(\{\s*path:\s*process\.env\.DOTENV_CONFIG_PATH,\s*override:\s*true\s*\}\)/, + /dotenv\.config\(\{\s*path:\s*process\.env\.DOTENV_CONFIG_PATH,\s*override:\s*true,\s*quiet:\s*true\s*\}\)/, ); }); }); diff --git a/tests/env-loading.test.ts b/tests/env-loading.test.ts index d7214a3..566290b 100644 --- a/tests/env-loading.test.ts +++ b/tests/env-loading.test.ts @@ -37,6 +37,15 @@ describe('env loading invariants', () => { expect(src).toMatch(/dotenv\.config\([^)]*override:\s*true/); }); + 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 + // JSON-RPC transport. A client that does not skip unparseable lines sees + // a corrupt stream and registers no tools, with no error to read. + const src = readFileSync(join(repoRoot, 'src/db.ts'), 'utf8'); + expect(src).toMatch(/dotenv\.config\([^)]*quiet:\s*true/); + }); + it('dotenv { override: true } overrides a pre-set env var', () => { // Guard against dotenv upgrade silently changing override semantics. const tmp = mkdtempSync(join(tmpdir(), 'env-test-')); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index a2ba7fc..a0465b8 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -23,7 +23,7 @@ const { Pool } = pg; // DATABASE_URL= DOTENV_CONFIG_PATH= vitest // would satisfy the guard and then write to the managed database anyway. // tests/db-guard.test.ts pins this pairing at the source level. -dotenv.config({ path: process.env.DOTENV_CONFIG_PATH, override: true }); +dotenv.config({ path: process.env.DOTENV_CONFIG_PATH, override: true, quiet: true }); // Guard against running this suite against a shared managed database. // Enforced at module load, before the pool is constructed, so it protects