custom-stdio.ts•10.7 kB
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import process from "node:process";
interface LogNotification {
  jsonrpc: "2.0";
  method: "notifications/message";
  params: {
    level: "emergency" | "alert" | "critical" | "error" | "warning" | "notice" | "info" | "debug";
    logger?: string;
    data: any;
  };
}
/**
 * Enhanced StdioServerTransport that wraps console output in valid JSON-RPC structures
 * instead of filtering them out. This prevents crashes while maintaining debug visibility.
 */
export class FilteredStdioServerTransport extends StdioServerTransport {
  private originalConsole: {
    log: typeof console.log;
    warn: typeof console.warn;
    error: typeof console.error;
    debug: typeof console.debug;
    info: typeof console.info;
  };
  private originalStdoutWrite: typeof process.stdout.write;
  private isInitialized: boolean = false;
  private messageBuffer: Array<{
    level: "emergency" | "alert" | "critical" | "error" | "warning" | "notice" | "info" | "debug";
    args: any[];
    timestamp: number;
  }> = [];
  constructor() {
    super();
    
    // Store original methods
    this.originalConsole = {
      log: console.log,
      warn: console.warn,
      error: console.error,
      debug: console.debug,
      info: console.info,
    };
    
    this.originalStdoutWrite = process.stdout.write;
    
    // Setup console redirection
    this.setupConsoleRedirection();
    
    // Setup stdout filtering for any other output
    this.setupStdoutFiltering();
    
    // Note: We defer the initialization notification until enableNotifications() is called
    // to ensure MCP protocol compliance - notifications must not be sent before initialization
  }
  /**
   * Call this method after MCP initialization is complete to enable JSON-RPC notifications
   */
  public enableNotifications() {
    this.isInitialized = true;
    
    // Send the deferred initialization notification first
    this.sendLogNotification('info', ['Enhanced FilteredStdioServerTransport initialized']);
    
    // Replay all buffered messages in chronological order
    if (this.messageBuffer.length > 0) {
      this.sendLogNotification('info', [`Replaying ${this.messageBuffer.length} buffered initialization messages`]);
      
      this.messageBuffer
        .sort((a, b) => a.timestamp - b.timestamp)
        .forEach(msg => {
          this.sendLogNotification(msg.level, msg.args);
        });
      
      // Clear the buffer
      this.messageBuffer = [];
    }
    
    this.sendLogNotification('info', ['JSON-RPC notifications enabled']);
  }
  /**
   * Check if notifications are enabled
   */
  public get isNotificationsEnabled(): boolean {
    return this.isInitialized;
  }
  /**
   * Get the current count of buffered messages
   */
  public get bufferedMessageCount(): number {
    return this.messageBuffer.length;
  }
  private setupConsoleRedirection() {
    console.log = (...args: any[]) => {
      if (this.isInitialized) {
        this.sendLogNotification("info", args);
      } else {
        // Buffer for later replay to client
        this.messageBuffer.push({
          level: "info",
          args,
          timestamp: Date.now()
        });
      }
    };
    console.info = (...args: any[]) => {
      if (this.isInitialized) {
        this.sendLogNotification("info", args);
      } else {
        this.messageBuffer.push({
          level: "info",
          args,
          timestamp: Date.now()
        });
      }
    };
    console.warn = (...args: any[]) => {
      if (this.isInitialized) {
        this.sendLogNotification("warning", args);
      } else {
        this.messageBuffer.push({
          level: "warning",
          args,
          timestamp: Date.now()
        });
      }
    };
    console.error = (...args: any[]) => {
      if (this.isInitialized) {
        this.sendLogNotification("error", args);
      } else {
        this.messageBuffer.push({
          level: "error",
          args,
          timestamp: Date.now()
        });
      }
    };
    console.debug = (...args: any[]) => {
      if (this.isInitialized) {
        this.sendLogNotification("debug", args);
      } else {
        this.messageBuffer.push({
          level: "debug",
          args,
          timestamp: Date.now()
        });
      }
    };
  }
  private setupStdoutFiltering() {
    process.stdout.write = (buffer: any, encoding?: any, callback?: any): boolean => {
      // Handle different call signatures
      if (typeof buffer === 'string') {
        const trimmed = buffer.trim();
        
        // Check if this looks like a valid JSON-RPC message
        if (trimmed.startsWith('{') && (
          trimmed.includes('"jsonrpc"') || 
          trimmed.includes('"method"') || 
          trimmed.includes('"id"')
        )) {
          // This looks like a valid JSON-RPC message, allow it
          return this.originalStdoutWrite.call(process.stdout, buffer, encoding, callback);
        } else if (trimmed.length > 0) {
          // Non-JSON-RPC output, wrap it in a log notification
          if (this.isInitialized) {
            this.sendLogNotification("info", [buffer.replace(/\n$/, '')]);
          } else {
            // Buffer for later replay to client
            this.messageBuffer.push({
              level: "info",
              args: [buffer.replace(/\n$/, '')],
              timestamp: Date.now()
            });
          }
          if (callback) callback();
          return true;
        }
      }
      
      // For non-string buffers or empty strings, let them through
      return this.originalStdoutWrite.call(process.stdout, buffer, encoding, callback);
    };
  }
  private sendLogNotification(level: "emergency" | "alert" | "critical" | "error" | "warning" | "notice" | "info" | "debug", args: any[]) {
    try {
      // For data, we can send structured data or string according to MCP spec
      let data: any;
      if (args.length === 1 && typeof args[0] === 'object' && args[0] !== null) {
        // Single object - send as structured data
        data = args[0];
      } else {
        // Multiple args or primitives - convert to string
        data = args.map(arg => {
          if (typeof arg === 'object') {
            try {
              return JSON.stringify(arg, null, 2);
            } catch {
              return String(arg);
            }
          }
          return String(arg);
        }).join(' ');
      }
      const notification: LogNotification = {
        jsonrpc: "2.0",
        method: "notifications/message",
        params: {
          level: level,
          logger: "desktop-commander",
          data: data
        }
      };
      // Send as valid JSON-RPC notification
      this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n');
    } catch (error) {
      // Fallback to a simple JSON-RPC error notification if JSON serialization fails
      const fallbackNotification = {
        jsonrpc: "2.0" as const,
        method: "notifications/message",
        params: {
          level: "error",
          logger: "desktop-commander",
          data: `Log serialization failed: ${args.join(' ')}`
        }
      };
      this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n');
    }
  }
  /**
   * Public method to send log notifications from anywhere in the application
   */
  public sendLog(level: "emergency" | "alert" | "critical" | "error" | "warning" | "notice" | "info" | "debug", message: string, data?: any) {
    try {
      const notification: LogNotification = {
        jsonrpc: "2.0",
        method: "notifications/message",
        params: {
          level: level,
          logger: "desktop-commander",
          data: data ? { message, ...data } : message
        }
      };
      this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n');
    } catch (error) {
      // Fallback to basic JSON-RPC notification
      const fallbackNotification = {
        jsonrpc: "2.0" as const,
        method: "notifications/message",
        params: {
          level: "error",
          logger: "desktop-commander",
          data: `sendLog failed: ${message}`
        }
      };
      this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n');
    }
  }
  /**
   * Send a progress notification (useful for long-running operations)
   */
  public sendProgress(token: string, value: number, total?: number) {
    try {
      const notification = {
        jsonrpc: "2.0" as const,
        method: "notifications/progress",
        params: {
          progressToken: token,
          value: value,
          ...(total && { total })
        }
      };
      
      this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n');
    } catch (error) {
      // Fallback to basic JSON-RPC notification for progress
      const fallbackNotification = {
        jsonrpc: "2.0" as const,
        method: "notifications/message",
        params: {
          level: "info",
          logger: "desktop-commander",
          data: `Progress ${token}: ${value}${total ? `/${total}` : ''}`
        }
      };
      this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n');
    }
  }
  /**
   * Send a custom notification with any method name
   */
  public sendCustomNotification(method: string, params: any) {
    try {
      const notification = {
        jsonrpc: "2.0" as const,
        method: method,
        params: params
      };
      
      this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n');
    } catch (error) {
      // Fallback to basic JSON-RPC notification for custom notifications
      const fallbackNotification = {
        jsonrpc: "2.0" as const,
        method: "notifications/message",
        params: {
          level: "error",
          logger: "desktop-commander",
          data: `Custom notification failed: ${method}: ${JSON.stringify(params)}`
        }
      };
      this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n');
    }
  }
  /**
   * Cleanup method to restore original console methods if needed
   */
  public cleanup() {
    if (this.originalConsole) {
      console.log = this.originalConsole.log;
      console.warn = this.originalConsole.warn;
      console.error = this.originalConsole.error;
      console.debug = this.originalConsole.debug;
      console.info = this.originalConsole.info;
    }
    
    if (this.originalStdoutWrite) {
      process.stdout.write = this.originalStdoutWrite;
    }
  }
}