pi-mcp-extension
Version:
Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.
368 lines (327 loc) • 14.1 kB
text/typescript
/**
* Opt-in diagnostic log for pi-mcp.
*
* Two related responsibilities:
*
* 1. `DebugLog` — a redacting, size-capped JSON Lines log file written to
* `<agentDir>/logs/pi-mcp.log`, enabled with `PI_MCP_DEBUG=1`. Writes are
* best-effort and never block or break a connection or tool call.
* 2. `reportDiagnostic` — the process-wide sink that replaces the extension's
* `console.error` calls. While a UI is active nothing is written to Pi's
* stderr (the TUI owns that stream); user-actionable events are surfaced
* through `ctx.ui.notify(...)` instead. Without a UI (bootstrap before
* `session_start`, headless runs) warn/error events (and info events
* flagged `stderr: true`, such as child-server output) use stderr; other
* info-level telemetry is log-file only, so headless stderr keeps exactly
* the output it had before this sink existed.
*
* Redaction is allow-list first — only known-safe field names are serialized —
* and pattern-based second, as a safety net for values that were passed by
* mistake.
*/
import { appendFile, chmod, mkdir, rename, stat } from "node:fs/promises";
import { join } from "node:path";
import {
buildLogEntry,
formatLogLine,
sanitizeLogText,
type BuildLogEntryParams,
type DiagnosticLevel,
type LogFields,
} from "./debug-log-format.js";
export {
buildLogEntry,
DEBUG_LOG_MAX_SERVER_LENGTH,
DEBUG_LOG_MAX_TEXT_LENGTH,
describeError,
formatLogLine,
SAFE_FIELD_NAMES,
sanitizeLogFields,
sanitizeLogText,
type BuildLogEntryParams,
type DebugLogEntry,
type DiagnosticLevel,
type LogFields,
type LogFieldValue,
} from "./debug-log-format.js";
export { redactSecrets } from "./secret-redaction.js";
// ─── Constants ────────────────────────────────────────────────────────────────
/** Environment variable that turns the debug log on. */
export const DEBUG_LOG_ENV_VAR = "PI_MCP_DEBUG";
/** Log file name inside the log directory. */
export const DEBUG_LOG_FILE_NAME = "pi-mcp.log";
/** Name of the single rotated backup kept next to the log file. */
export const DEBUG_LOG_ROTATED_FILE_NAME = "pi-mcp.log.1";
/** Maximum size of the log file before it is rotated (~2 MB). */
export const DEBUG_LOG_MAX_BYTES = 2 * 1024 * 1024;
/** Permissions of the log directory — same as the credential directories. */
export const DEBUG_LOG_DIR_MODE = 0o700;
/** Permissions of the log file — same as the credential files. */
export const DEBUG_LOG_FILE_MODE = 0o600;
// ─── File Logger ──────────────────────────────────────────────────────────────
export interface DebugLogFs {
mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Promise<unknown>;
appendFile(path: string, data: string, options?: { mode?: number }): Promise<void>;
stat(path: string): Promise<{ size: number }>;
rename(oldPath: string, newPath: string): Promise<void>;
chmod(path: string, mode: number): Promise<void>;
}
export interface DebugLogDeps {
fs: DebugLogFs;
now: () => Date;
}
export function createDefaultDebugLogDeps(): DebugLogDeps {
return {
fs: {
mkdir: (path, options) => mkdir(path, options),
appendFile: (path, data, options) => appendFile(path, data, options),
stat: (path) => stat(path),
rename: (oldPath, newPath) => rename(oldPath, newPath),
chmod: (path, mode) => chmod(path, mode),
},
now: () => new Date(),
};
}
/** True when `PI_MCP_DEBUG` asks for the debug log. */
export function isDebugLogEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = env[DEBUG_LOG_ENV_VAR];
if (typeof raw !== "string") return false;
const value = raw.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes" || value === "on";
}
/**
* A size-capped JSON Lines log file.
*
* Writes are queued and executed in order, but never awaited by the caller:
* a logging failure must not break a connection or a tool call.
*/
export class DebugLog {
readonly dir: string;
readonly filePath: string;
readonly rotatedFilePath: string;
private readonly deps: DebugLogDeps;
private queue: Promise<void> = Promise.resolve();
private dirReady = false;
private fileModeEnsured = false;
constructor(dir: string, deps?: Partial<DebugLogDeps>) {
this.dir = dir;
this.filePath = join(dir, DEBUG_LOG_FILE_NAME);
this.rotatedFilePath = join(dir, DEBUG_LOG_ROTATED_FILE_NAME);
this.deps = { ...createDefaultDebugLogDeps(), ...deps };
}
/** Queue one event. Never throws, never blocks the caller. */
write(params: Omit<BuildLogEntryParams, "ts"> & { ts?: Date | undefined }): void {
const line = formatLogLine(
buildLogEntry({ ...params, ts: params.ts ?? this.deps.now() }),
);
this.queue = this.queue
.then(() => this._append(line))
.catch((err: unknown) => {
reportLoggerFailure(err);
});
}
/** Wait for all queued writes to settle. Used by tests and on shutdown. */
async flush(): Promise<void> {
await this.queue;
}
private async _append(line: string): Promise<void> {
await this._ensureDir();
await this._rotateIfNeeded(Buffer.byteLength(line, "utf8"));
try {
await this.deps.fs.appendFile(this.filePath, line, { mode: DEBUG_LOG_FILE_MODE });
} catch (err) {
// WHY: `ENOENT` on `appendFile` means `<agentDir>/logs` disappeared after
// it was created. Recreating the directory and retrying once keeps that
// from dropping the entry or tripping the once-per-process failure latch;
// a second failure is a real one.
if (!isErrnoException(err, "ENOENT")) throw err;
this.dirReady = false;
this.fileModeEnsured = false;
await this._ensureDir();
await this.deps.fs.appendFile(this.filePath, line, { mode: DEBUG_LOG_FILE_MODE });
}
await this._ensureFileMode();
}
private async _ensureDir(): Promise<void> {
if (this.dirReady) return;
try {
await this._createDir();
} catch (err) {
// WHY: Something removed `<agentDir>/logs` between the mkdir and the chmod
// (a peer Pi process pruning an agent dir, a sandbox reset). Recreating it
// keeps that from dropping the entry; a second failure is a real one.
if (!isErrnoException(err, "ENOENT")) throw err;
await this._createDir();
}
this.dirReady = true;
}
private async _createDir(): Promise<void> {
const fs = this.deps.fs;
await fs.mkdir(this.dir, { recursive: true, mode: DEBUG_LOG_DIR_MODE });
await fs.chmod(this.dir, DEBUG_LOG_DIR_MODE);
}
private async _ensureFileMode(): Promise<void> {
if (this.fileModeEnsured) return;
try {
// WHY: `mode` only applies when the file is created; an existing file may
// have looser permissions, so the mode is enforced once per logger.
await this.deps.fs.chmod(this.filePath, DEBUG_LOG_FILE_MODE);
this.fileModeEnsured = true;
} catch (err) {
// WHY: A peer Pi process sharing this log rotated `pi-mcp.log` between the
// append and this chmod, so it already holds the line. Leaving the flag
// false re-applies the mode on the file the next write creates instead of
// reporting a logger failure for a race that lost nothing.
if (!isErrnoException(err, "ENOENT")) throw err;
}
}
private async _rotateIfNeeded(incomingBytes: number): Promise<void> {
const size = await this._logSize();
if (size === null || size <= 0) return;
if (size + incomingBytes <= DEBUG_LOG_MAX_BYTES) return;
// WHY: Re-read immediately before renaming to narrow the lock-free race
// window: if the log vanished or shrank (`currentSize < size`), a peer
// process already rotated it (and possibly recreated a smaller one), so
// renaming again would overwrite the backup that peer just produced. A plain
// peer append (`currentSize >= size`) leaves the file over the cap and still
// rotates. Either way, `pi-mcp.log` is no longer the pre-rotation file, so
// `0o600` must be re-enforced after the append.
const currentSize = await this._logSize();
if (currentSize === null) return;
this.fileModeEnsured = false;
if (currentSize < size) return;
await this._rotate();
}
/** Size of the log file: `0` when it is absent, `null` when it is unknown. */
private async _logSize(): Promise<number | null> {
try {
return (await this.deps.fs.stat(this.filePath)).size;
} catch (err) {
if (isErrnoException(err, "ENOENT")) {
// WHY: An absent file means the initial write has not run yet or a peer
// rotated `pi-mcp.log` away since this logger last ensured `0o600`.
this.fileModeEnsured = false;
return 0;
}
// WHY: Any other stat failure leaves the size unknown. Rotation is only an
// optimization — the caller appends anyway rather than dropping the line,
// and the cap is re-checked on the next write that can read the size.
return null;
}
}
private async _rotate(): Promise<void> {
this.fileModeEnsured = false;
try {
// WHY: One rename, no unlink: `rename` replaces the previous backup
// directly (POSIX `rename(2)`, Windows `MoveFileExW`), so there is no
// unlink-before-rename gap in which a peer process that rotated in the
// meantime loses the 2 MB of history its backup just preserved.
await this.deps.fs.rename(this.filePath, this.rotatedFilePath);
} catch (err) {
// WHY: A peer Pi process sharing this log rotated `pi-mcp.log` first, so
// there is nothing to move — its backup is the one that stays. The append
// below recreates the file and writes the incoming line into it.
if (!isErrnoException(err, "ENOENT")) throw err;
}
}
}
/** True when `err` is a Node.js errno exception carrying `code`. */
function isErrnoException(err: unknown, code: string): boolean {
return (err as NodeJS.ErrnoException | null)?.code === code;
}
// ─── Diagnostic Sink ──────────────────────────────────────────────────────────
export interface Diagnostic {
level: DiagnosticLevel;
/** Machine-readable event id, e.g. `server.state`. */
event: string;
/** Human-readable summary. */
message: string;
server?: string | undefined;
fields?: LogFields | undefined;
/** Surface the event in the Pi UI when no log file is available. */
notify?: boolean | undefined;
/**
* Also write to stderr in headless mode (no UI) even for info-level events —
* for output that historically went to stderr, such as child-server logs.
* warn/error events always reach stderr when no UI is active.
*/
stderr?: boolean | undefined;
}
export interface DiagnosticNotifier {
notify(message: string, level: "info" | "warning" | "error"): void;
}
let activeLogger: DebugLog | null = null;
let activeNotifier: DiagnosticNotifier | null = null;
let uiActive = false;
let loggerFailureReported = false;
/** Install (or clear) the debug log file writer. */
export function setDebugLog(logger: DebugLog | null): void {
activeLogger = logger;
loggerFailureReported = false;
}
export function getDebugLog(): DebugLog | null {
return activeLogger;
}
/**
* Tell the sink whether a UI is active.
*
* `hasUI` mirrors `ExtensionContext.hasUI`: while it is true, nothing is ever
* written to Pi's stderr.
*/
export function setDiagnosticUi(notifier: DiagnosticNotifier | null, hasUI: boolean): void {
activeNotifier = notifier;
uiActive = hasUI && notifier !== null;
}
export function isUiActive(): boolean {
return uiActive;
}
/**
* Report a diagnostic event.
*
* - With a debug log: the event is written to the log file.
* - Without a debug log and with an active UI: the event is dropped, or
* surfaced through `ctx.ui.notify(...)` when it is user-actionable.
* - Without a UI: warn/error events (and info events flagged `stderr`) go to
* stderr. Other info-level telemetry is log-file only, so headless stderr
* keeps exactly the output it had before this sink existed.
*/
export function reportDiagnostic(diagnostic: Diagnostic): void {
const message = sanitizeLogText(diagnostic.message, 500);
if (activeLogger) {
activeLogger.write({
level: diagnostic.level,
event: diagnostic.event,
server: diagnostic.server,
message,
fields: diagnostic.fields,
});
}
if (uiActive) {
if (diagnostic.notify && activeNotifier) {
const level = diagnostic.level === "warn" ? "warning" : diagnostic.level;
activeNotifier.notify(`pi-mcp: ${message}`, level);
}
return;
}
// WHY: info-level telemetry (state transitions, token refresh, session
// lifecycle) would be new noise on headless stderr — before the sink these
// events did not exist. Only warn/error events and info events flagged
// `stderr` (child-server output, the headless OAuth URL fallback) reach it.
if (diagnostic.level === "info" && diagnostic.stderr !== true) return;
console.error(`[pi-mcp] ${message}`);
}
/**
* Report a failure of the log writer itself. A broken log must never break a
* connection, so the failure is reported once and then ignored.
*/
function reportLoggerFailure(err: unknown): void {
if (loggerFailureReported) return;
loggerFailureReported = true;
if (uiActive) {
// WHY: While a UI is active, stderr belongs to the TUI. The failed log write
// is silently ignored — logging must never break a connection or a tool call.
return;
}
const detail = err instanceof Error ? err.message : String(err);
console.error(`[pi-mcp] Debug log write failed: ${sanitizeLogText(detail, 200)}`);
}