UNPKG

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.

201 lines (178 loc) • 6.45 kB
/** * Pure diagnostic log formatting, field allow-listing, and error classification. * * Owns pure diagnostic log field allow-listing, log-text sanitization, error * classification, and JSON Lines entry construction/formatting. Kept pure * (no I/O, no filesystem, no process stderr/stdout) so it can be tested and * reasoned about in isolation. */ import { McpError } from "./errors.js"; import { redactSecrets } from "./secret-redaction.js"; import { sanitizeTerminalText } from "./terminal-text.js"; export { redactSecrets }; // ─── Constants ──────────────────────────────────────────────────────────────── /** Maximum length of a single logged string. */ export const DEBUG_LOG_MAX_TEXT_LENGTH = 300; /** Maximum length of a server name in a log field. */ export const DEBUG_LOG_MAX_SERVER_LENGTH = 100; export type DiagnosticLevel = "info" | "warn" | "error"; export type LogFieldValue = string | number | boolean; export type LogFields = Record<string, LogFieldValue | undefined>; /** * Field names that may be serialized. Anything else is dropped: an unknown * field is far more likely to carry a secret than anything useful. */ export const SAFE_FIELD_NAMES: readonly string[] = [ "server", "from", "to", "transport", "lifecycle", "outcome", "reason", "phase", "trigger", "kind", "logLevel", "logger", "attempt", "retries", "delayMs", "ms", "count", "pages", "tools", "peerReloaded", "exitCode", "signal", "scope", ]; const SAFE_FIELD_NAME_SET = new Set(SAFE_FIELD_NAMES); // ─── Sanitization ───────────────────────────────────────────────────────────── /** * Make a string safe to store in the log: strip terminal escapes, bound the * length, and mask token-like substrings. */ export function sanitizeLogText( text: string, maxLength: number = DEBUG_LOG_MAX_TEXT_LENGTH, ): string { return redactSecrets(sanitizeTerminalText(text, { maxLength })); } /** * Sanitize a single allow-listed field value. * Non-finite numbers and `undefined` are dropped (`undefined`) so they never * reach the log line. */ function sanitizeFieldValue(value: LogFieldValue | undefined): LogFieldValue | undefined { if (value === undefined) return undefined; if (typeof value === "number") { return Number.isFinite(value) ? value : undefined; } if (typeof value === "boolean") return value; return sanitizeLogText(value); } /** * Keep only allow-listed field names, and sanitize the values of the ones * that are kept. */ export function sanitizeLogFields(fields: LogFields | undefined): Record<string, LogFieldValue> { const out: Record<string, LogFieldValue> = {}; if (!fields) return out; for (const [key, value] of Object.entries(fields)) { if (!SAFE_FIELD_NAME_SET.has(key)) continue; const sanitized = sanitizeFieldValue(value); if (sanitized === undefined) continue; out[key] = sanitized; } return out; } function safeErrorText(read: () => string, fallback: string): string { try { return read(); } catch { // WHY: Null-prototype objects (`Object.create(null)`) and hostile `message` / `toString` // getters throw `TypeError`; diagnostic error classification must never throw. return fallback; } } function sanitizedErrorKind(read: () => string): string { const kind = sanitizeLogText(safeErrorText(read, "error"), DEBUG_LOG_MAX_SERVER_LENGTH).trim(); return kind || "error"; } /** * Describe an error for a log field: a stable kind plus a sanitized message. * Never logs the raw error object (it may carry tokens in its properties). */ export function describeError(err: unknown): { kind: string; message: string } { let classification: "mcp" | "error" | "unknown" = "unknown"; try { // WHY: A revoked Proxy can throw even during instanceof classification. if (err instanceof McpError) classification = "mcp"; else if (err instanceof Error) classification = "error"; } catch { return { kind: "unknown", message: "[unreadable error]" }; } if (classification === "mcp") { const error = err as McpError; return { kind: sanitizedErrorKind(() => String(error.code || "error")), message: sanitizeLogText(safeErrorText(() => String(error.message), "[unreadable error message]")), }; } if (classification === "error") { const error = err as Error; return { kind: sanitizedErrorKind(() => String(error.name || "error")), message: sanitizeLogText(safeErrorText(() => String(error.message), "[unreadable error message]")), }; } return { kind: "unknown", message: sanitizeLogText(safeErrorText(() => String(err), "[unstringifiable value]")), }; } // ─── Log Entry ──────────────────────────────────────────────────────────────── export interface DebugLogEntry { ts: string; level: DiagnosticLevel; event: string; server?: string; message?: string; [key: string]: unknown; } export interface BuildLogEntryParams { ts: Date; level: DiagnosticLevel; event: string; server?: string | undefined; message?: string | undefined; fields?: LogFields | undefined; } /** * Build the JSON object for a single log event. Field values are sanitized * and unknown field names are dropped. */ export function buildLogEntry(params: BuildLogEntryParams): DebugLogEntry { const fields = sanitizeLogFields(params.fields); const entry: DebugLogEntry = { ts: params.ts.toISOString(), level: params.level, event: params.event, }; if (params.server !== undefined && params.server !== "") { entry.server = sanitizeLogText(params.server, DEBUG_LOG_MAX_SERVER_LENGTH); } if (params.message !== undefined) { entry.message = sanitizeLogText(params.message); } for (const [key, value] of Object.entries(fields)) { if (key === "message") continue; entry[key] = value; } return entry; } /** Serialize a log entry as a single JSON Lines record (one line, no newline). */ export function formatLogLine(entry: DebugLogEntry): string { return `${JSON.stringify(entry)}\n`; }