LoggingStdioTransport.php•8.29 kB
<?php
declare(strict_types=1);
namespace OpenFGA\MCP;
use Override;
use PhpMcp\Schema\JsonRpc\{Error, Message, Notification, Parser, Request, Response};
use PhpMcp\Server\Exception\TransportException;
use PhpMcp\Server\Transports\StdioServerTransport;
use React\Promise\PromiseInterface;
use Throwable;
use function array_combine;
use function array_keys;
use function array_map;
use function array_values;
use function count;
use function error_log;
use function function_exists;
use function is_array;
use function is_numeric;
use function is_string;
final class LoggingStdioTransport extends StdioServerTransport
{
    private string $messageBuffer = '';
    /**
     * @throws TransportException
     */
    public function __construct()
    {
        parent::__construct();
        // Only output debug messages when not in testing environment
        // Check if we're running tests by looking for test functions or PHPUnit classes
        $inTestEnvironment = function_exists('test') || function_exists('it') || class_exists('PHPUnit\Framework\TestCase');
        if (! $inTestEnvironment) {
            error_log('[MCP DEBUG] LoggingStdioTransport initialized - logging is ACTIVE');
        }
    }
    /**
     * Override listen to intercept and fix JSON before parsing.
     */
    #[Override]
    public function listen(): void
    {
        // Call parent to set up streams and basic handlers
        parent::listen();
        // Remove the parent's data handler and add our custom one
        $this->stdin?->removeAllListeners('data');
        $this->stdin?->on('data', function (mixed $chunk): void {
            if (is_string($chunk)) {
                $this->messageBuffer .= $chunk;
                $this->processBufferWithFixes();
            }
        });
    }
    /**
     * Override sendMessage to log responses.
     *
     * @phpstan-param array<mixed> $context
     *
     * @return PromiseInterface<void>
     */
    #[Override]
    public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface
    {
        // Log the outgoing message
        $this->logOutgoingMessage($message);
        return parent::sendMessage($message, $sessionId, $context);
    }
    /**
     * Fix tool call JSON by ensuring arguments field exists.
     * This prevents CallToolRequest constructor errors.
     *
     * @param string $jsonString
     */
    private function fixToolCallJson(string $jsonString): string
    {
        try {
            $data = json_decode($jsonString, true, 512, JSON_THROW_ON_ERROR);
            if (! is_array($data)) {
                return $jsonString;
            }
            // Check if this is a tools/call request
            if (
                isset($data['method'])
                && is_string($data['method'])
                && 'tools/call' === $data['method']
                && isset($data['params'])
                && is_array($data['params'])
            ) {
                // Ensure params has arguments field as an array
                if (! isset($data['params']['arguments'])) {
                    $data['params']['arguments'] = [];
                    $toolName = isset($data['params']['name']) && is_string($data['params']['name']) ? $data['params']['name'] : 'unknown';
                    $inTestEnvironment = function_exists('test') || function_exists('it') || class_exists('PHPUnit\Framework\TestCase');
                    if (! $inTestEnvironment) {
                        error_log('[MCP DEBUG] Added missing arguments array for tool call: ' . $toolName);
                    }
                } elseif (! is_array($data['params']['arguments'])) {
                    // Convert non-arrays to empty array
                    $data['params']['arguments'] = [];
                    $toolName = isset($data['params']['name']) && is_string($data['params']['name']) ? $data['params']['name'] : 'unknown';
                    $inTestEnvironment = function_exists('test') || function_exists('it') || class_exists('PHPUnit\Framework\TestCase');
                    if (! $inTestEnvironment) {
                        error_log('[MCP DEBUG] Converted non-array arguments to array for tool call: ' . $toolName);
                    }
                }
                return json_encode($data, JSON_THROW_ON_ERROR);
            }
            // Not a tool call, return original
            return $jsonString;
        } catch (Throwable $throwable) {
            // If JSON parsing fails, return original string
            $inTestEnvironment = function_exists('test') || function_exists('it') || class_exists('PHPUnit\Framework\TestCase');
            if (! $inTestEnvironment) {
                error_log('[MCP DEBUG] Failed to fix JSON, using original: ' . $throwable->getMessage());
            }
            return $jsonString;
        }
    }
    /**
     * @param Notification|Request $message
     */
    private function logIncomingMessage(Request | Notification $message): void
    {
        $params = [];
        $id = null;
        // Get method - both Request and Notification have this property
        $method = $message->method;
        // Get params if available
        if (null !== $message->params) {
            // Convert array keys to strings
            $stringKeys = array_map('strval', array_keys($message->params));
            $stringKeyedParams = array_combine($stringKeys, array_values($message->params));
            $params = $stringKeyedParams;
        }
        // Get ID if it's a Request (Notification doesn't have id)
        if ($message instanceof Request) {
            $messageId = $message->getId();
            if (is_string($messageId) || is_numeric($messageId)) {
                $id = (string) $messageId;
            }
        }
        // Log the request
        DebugLogger::logRequest(
            method: $method,
            params: $params,
            id: $id,
        );
    }
    private function logOutgoingMessage(Message $message): void
    {
        /** @var array<string, mixed> $response */
        $response = [];
        // Handle different message types
        if ($message instanceof Error) {
            $response['error'] = [
                'code' => $message->code,
                'message' => $message->message,
                'data' => $message->data ?? null,
            ];
        } elseif ($message instanceof Response) {
            // Response always has result
            $response['result'] = $message->result;
        }
        // Get ID
        $id = $message->getId();
        $response['id'] = is_string($id) || is_numeric($id) ? (string) $id : null;
        if (0 < count($response)) {
            DebugLogger::logResponse(
                response: $response,
                id: $response['id'] ?? null,
            );
        }
    }
    /**
     * Process buffer with JSON fixes applied before parsing.
     * This method fixes tool call requests that are missing arguments.
     */
    private function processBufferWithFixes(): void
    {
        while (str_contains($this->messageBuffer, "\n")) {
            $pos = strpos($this->messageBuffer, "\n");
            if (false === $pos) {
                break;
            }
            $line = substr($this->messageBuffer, 0, $pos);
            $this->messageBuffer = substr($this->messageBuffer, $pos + 1);
            $trimmedLine = trim($line);
            if ('' === $trimmedLine) {
                continue;
            }
            try {
                // Fix the JSON before parsing
                $fixedJson = $this->fixToolCallJson($trimmedLine);
                $message = Parser::parse($fixedJson);
                // Log the incoming message
                if ($message instanceof Request || $message instanceof Notification) {
                    $this->logIncomingMessage($message);
                }
                $this->emit('message', [$message, 'stdio']);
            } catch (Throwable $e) {
                $this->logger->error('Error parsing message', ['exception' => $e]);
                $error = Error::forParseError('Invalid JSON: ' . $e->getMessage());
                $this->sendMessage($error, 'stdio');
                continue;
            }
        }
    }
}