Research notes for the Outfitter Stack. Synthesized from codebase analysis, MCP spec, CLI guidelines, and logging library documentation.
Output is the product — what the user asked for. Logging is the process — how the program ran.
| Concern | Stream | Audience | Example |
|---|---|---|---|
| Output | stdout | User / pipe consumer | outfitter list results, JSON data |
| Diagnostics | stderr | Operator / debugger | Warnings, progress, spinners, log lines |
| Errors | stderr (human) or stdout (JSON mode) | User | Validation failures, not-found |
These are fundamentally different systems and should never be conflated.
$ my-tool list 2>/dev/null | jq '.' # stdout = data, stderr discarded
$ my-tool list --json 2>&1 | grep error # both streams merged for grep
- stdout: Line-buffered on TTY, block-buffered on pipe. ~2x faster than stderr (Orhun Parmaksiz, 2024).
- stderr: Unbuffered — every write is a syscall. Use
BufWriterfor high-volume diagnostic output.
From clig.dev:
"The primary output for your command should go to stdout. Log messages, errors, and so on should all be sent to stderr."
From 12 Factor CLI Apps (Jeff Dickey / oclif creator):
"Using stdout for informational messages makes utilities unusable for pipeable CLIs."
Both ESLint's no-console and Biome's noConsole exist to catch debugging leftovers. But ESLint's own docs note:
"If you are developing for Node.js, console is used to output information to the user and so is not strictly used for debugging purposes."
The right approach for monorepos:
| Layer | noConsole |
Rationale |
|---|---|---|
Libraries (packages/*) |
error |
Must be transport-agnostic; callers control output |
| Handlers / domain logic | error |
Return Result<T, E>, never side-effect |
CLI entry points (apps/*) |
off or allow: ["error", "warn"] |
This IS the output layer |
| Test files | off |
Tests verify output behavior |
| Build scripts | off |
Developer tooling |
The rule isn't "never use console" — it's "only use console at the presentation boundary."
┌─────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ CLI commands, MCP tool results, HTTP responses │
│ ✓ console / output() / process.stdout.write() │
│ Decides: format, stream, exit code │
└────────────────────┬────────────────────────────┘
│ calls
┌────────────────────┴────────────────────────────┐
│ HANDLER LAYER │
│ Pure functions: (input, ctx) → Result<T, E> │
│ ✗ No console, no direct I/O │
│ Uses: ctx.logger for diagnostics only │
│ Returns: typed Result for caller to render │
└────────────────────┬────────────────────────────┘
│ injected via
┌────────────────────┴────────────────────────────┐
│ INFRASTRUCTURE LAYER │
│ Logging, config, file I/O, database │
│ ✗ No console │
│ Provides: Logger interface, sinks, formatters │
│ Silent by default (no-op if unconfigured) │
└─────────────────────────────────────────────────┘
Key principle: Handlers never know about output format. The adapter decides. Libraries are silent. The application owns configuration.
Structured diagnostics with automatic redaction. Goes to stderr via configured sinks.
const getUser: Handler<Input, User, NotFoundError> = async (input, ctx) => {
ctx.logger.debug("Fetching user", { userId: input.id });
const user = await db.users.findById(input.id);
if (!user) {
ctx.logger.info("User not found", { userId: input.id });
return Result.err(NotFoundError.create("user", input.id));
}
return Result.ok(user);
};User-facing results. Respects --json, TTY detection, piping.
const result = await getUser(input, ctx);
if (result.isErr()) {
exitWithError(result.error); // stderr + exit code
}
output(result.value); // stdout, format auto-detectedThe MCP protocol is the transport; tool results are the output.
handler: async (input, ctx) => {
ctx.logger.debug("Processing tool call", { tool: "add" });
return Result.ok({ sum: input.a + input.b });
}The MCP spec defines a structured logging protocol via notifications/message with RFC 5424 severity levels. Critically:
"The server MUST NOT write anything to its stdout that is not a valid MCP message."
console.log() in an MCP server using stdio transport is fatal — it corrupts the JSON-RPC stream.
| Context | What to Use | Stream | Why |
|---|---|---|---|
| Handler trace | ctx.logger.debug() |
stderr (via sink) | Structured, redacted, filterable |
| Handler error | Result.err(error) |
N/A (returned) | Caller decides presentation |
| CLI result | output(data) |
stdout | Respects --json, TTY, piping |
| CLI error | exitWithError(error) |
stderr + exit code | Typed formatting + POSIX codes |
| MCP result | Result.ok(data) |
tool response | Protocol handles serialization |
| MCP diagnostic | ctx.logger.info() |
notifications/message |
Structured protocol logging |
| MCP NEVER | console.log() |
☠️ corrupts stdout | Breaks JSON-RPC stream |
Libraries should produce zero output unless the consuming application explicitly configures logging.
LogTape articulates this best:
"If the application doesn't configure LogTape,
logger.info()does nothing. No output, no errors, no side effects."
The LogTape library guide establishes:
- Don't configure logging in your library — configuration belongs to the application
- Namespace your log categories with your library name (e.g.,
["outfitter", "config"]) - Use structured fields —
logger.info("Loaded config", { path, duration })not string interpolation
This is why the no-op logger pattern exists:
// MCP server: silent unless caller provides a logger
const logger = providedLogger ?? createNoOpLogger();| Aspect | LogTape | Pino | Winston |
|---|---|---|---|
| Library-friendly | Yes (silent by default) | No (requires init) | No (requires init) |
| Zero dependencies | Yes | 1 dep | 6+ deps |
| Bundle size | 5.3 KB | 3.1 KB | 38.3 KB |
| Performance | Good | Excellent (5-10x Winston) | Moderate |
| Structured logging | Yes | Yes (JSON-first) | Yes |
For libraries: LogTape or a custom Logger interface (what Outfitter does). For applications: Any — Pino for performance, LogTape for simplicity, or a custom sink system.
When --json is active:
- stdout contains only valid JSON — no spinners, no color, no progress bars
- stderr continues normally — human diagnostics still visible
- Error payloads are JSON too — not just success responses
- The schema is versioned — breaking changes to JSON output are breaking changes to the CLI
From the Heroku CLI Style Guide:
"Care should be taken that commands do not change their stdout after general availability in ways that will break current scripts."
createLogger()factory with numeric log levels- TTY detection for color/clearing
- Message deduplication (
warnOnce) customLoggerconfig for full replacement- Routes through
console.log/console.warn/console.errorrespectively
- Rust binary with typed event channels (
TuiSender/AppReceiver) - Output modes:
full,hash-only,new-only,errors-only,none - Declarative
outputLogsinturbo.json
configureOutput()with separatewriteOutandwriteErrcallbacks- Explicitly separates streams at the framework level
- Separate
useStdoutanduseStderrhooks - Intercepts
console.logto prevent interference with React render loop <Static>component for permanent output above the interactive area
- All interactive prompts (spinners, selections) go to stderr
- stdout stays clean for pipeable output
// ❌ Handler is now coupled to terminal output
const handler = async (input, ctx) => {
console.log("Processing..."); // Goes to stdout, corrupts pipes
return Result.ok(data);
};// ❌ Logger is for diagnostics, not product output
ctx.logger.info(`Created ${files.length} files`); // User never sees this reliably// ❌ Breaks piping: `my-tool | jq` fails
console.log("Starting process..."); // diagnostic on stdout
console.log(JSON.stringify(result)); // data on stdout// ☠️ Fatal: corrupts JSON-RPC stream on stdio transport
console.log("Debug:", input); // This breaks the protocol-
Keep
noConsole: "error"in library packages. This is correct. Libraries return data; callers render. -
Allow console in CLI entry points only. Use Biome overrides for
apps/**and explicit output modules. -
Use
output()for CLI results, notconsole.log. The output function handles--json, TTY detection, and stream routing. -
Use
ctx.loggerfor handler diagnostics. It's structured, redacted, and routed to stderr. -
MCP servers must never
console.log. Usectx.loggerwhich routes tonotifications/messageor stderr. -
The logger is not for users. It's for operators, debuggers, and AI assistants reading diagnostics. If a human needs to see it, it should be in the command's output.
-
Error presentation belongs to the adapter. Handlers return
Result.err(...). The CLI adapter formats it (human or JSON) and writes to stderr with the correct exit code.
- Command Line Interface Guidelines (clig.dev)
- 12 Factor CLI Apps — Jeff Dickey
- Heroku CLI Style Guide
- ESLint no-console Rule
- Biome noConsole Rule
- LogTape: What is LogTape?
- LogTape: Using in Libraries
- LogTape: Comparison
- MCP Spec: Transports (2025-06-18)
- MCP Spec: Logging (2025-03-26)
- Vite Logger Source
- Commander.js configureOutput
- Why stdout is faster than stderr — Orhun's Blog
- Ink (React for CLIs)
- Turborepo CLI Architecture
- 11 Best Practices for Logging in Node.js — Better Stack
- LogTape for Libraries — Hackers' Pub