From ab8bbe07d245222569bf353ca672791367cce6c5 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 10 Oct 2026 03:55:25 +0200 Subject: [PATCH] [Server] Fix memory growing with every `tools/call` Reuse the decoded schema object, Opis caches each new one forever. --- CHANGELOG.md | 1 + src/Capability/Discovery/SchemaValidator.php | 23 +++++++----------- .../Discovery/SchemaValidatorTest.php | 24 +++++++++++++++++++ 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeba9ce7..8e8f9c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] Reject a `Tool` input schema whose `properties` is not an object or whose `required` is neither a list nor `null`, instead of silently replacing the member. Reject a `completion/complete` whose `argument` is missing `name` or `value`, instead of completing against an empty prefix. * Add `HttpTransport::getSessionId()` to read the server-minted `Mcp-Session-Id`: a request-scoped caller can persist it and pass it back through the constructor's `$headers` on a later transport. Always `null` on `2026-07-28`, which removed protocol-level sessions. * Fix `HttpTransport` omitting the `MCP-Protocol-Version` header on requests after the `initialize` handshake, including the closing `DELETE`. +* Fix memory growing with every `tools/call` in long-lived processes like FrankenPHP workers or stdio servers. * Fix OIDC discovery rejecting issuers with a trailing slash (e.g. Authentik, Auth0). * Fix `Client::connect()` rejecting an empty `serverInfo.version`, as the Python SDK sends, and leaving the transport open when initialization fails on a malformed result: the transport is now closed and the failure wrapped in a `ConnectionException`. * Fix stateless SSE streams holding back frames until close when PHP output buffering is enabled. diff --git a/src/Capability/Discovery/SchemaValidator.php b/src/Capability/Discovery/SchemaValidator.php index 61215fdd..8581e636 100644 --- a/src/Capability/Discovery/SchemaValidator.php +++ b/src/Capability/Discovery/SchemaValidator.php @@ -11,7 +11,6 @@ namespace Mcp\Capability\Discovery; -use Mcp\Exception\InvalidArgumentException; use Opis\JsonSchema\Errors\ValidationError; use Opis\JsonSchema\Validator; use Psr\Log\LoggerInterface; @@ -30,6 +29,11 @@ */ class SchemaValidator { + /** + * @var array + */ + private array $decodedSchemas = []; + public function __construct( private ?Validator $jsonSchemaValidator = null, private LoggerInterface $logger = new NullLogger(), @@ -48,16 +52,9 @@ public function validateAgainstJsonSchema(mixed $data, array|object $schema): ar { try { // --- Schema Preparation --- - if (\is_array($schema)) { - $schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); - $schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR); - } elseif (\is_object($schema)) { - // This might be overly cautious but safer against varied inputs. - $schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); - $schemaObject = json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR); - } else { - throw new InvalidArgumentException('Schema must be an array or object.'); - } + // Opis caches every schema object it sees for good, so the same schema has to map to the same object. + $schemaJson = json_encode($schema, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); + $schemaObject = $this->decodedSchemas[$schemaJson] ??= json_decode($schemaJson, false, 512, \JSON_THROW_ON_ERROR); // --- Data Preparation --- // Opis Validator generally prefers objects for object validation @@ -66,10 +63,6 @@ public function validateAgainstJsonSchema(mixed $data, array|object $schema): ar $this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]); return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Invalid schema definition provided (JSON error).']]; - } catch (InvalidArgumentException $e) { - $this->logger->error('MCP SDK: Invalid schema structure provided for validation.', ['exception' => $e]); - - return [['pointer' => '', 'keyword' => 'internal', 'message' => $e->getMessage()]]; } catch (\Throwable $e) { $this->logger->error('MCP SDK: Error preparing data/schema for validation.', ['exception' => $e]); diff --git a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php index 1028c97f..596fedc3 100644 --- a/tests/Unit/Capability/Discovery/SchemaValidatorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaValidatorTest.php @@ -13,6 +13,8 @@ use Mcp\Capability\Attribute\Schema; use Mcp\Capability\Discovery\SchemaValidator; +use Opis\JsonSchema\SchemaLoader; +use Opis\JsonSchema\Validator; use PHPUnit\Framework\TestCase; class SchemaValidatorTest extends TestCase @@ -24,6 +26,28 @@ protected function setUp(): void $this->validator = new SchemaValidator(); } + public function testRepeatedValidationDoesNotGrowSchemaLoaderCache(): void + { + $loader = new class extends SchemaLoader { + public function cacheSize(): int + { + return \count($this->dataCache) + \count($this->uriCache); + } + }; + $validator = new SchemaValidator(new Validator($loader)); + + $validator->validateAgainstJsonSchema($this->getValidData(), $this->getSimpleSchema()); + $validator->validateAgainstJsonSchema($this->getValidData(), (object) $this->getSimpleSchema()); + $size = $loader->cacheSize(); + + for ($i = 0; $i < 10; ++$i) { + $validator->validateAgainstJsonSchema($this->getValidData(), $this->getSimpleSchema()); + $validator->validateAgainstJsonSchema($this->getValidData(), (object) $this->getSimpleSchema()); + } + + $this->assertSame($size, $loader->cacheSize()); + } + // --- Basic Validation Tests --- public function testValidDataPassesValidation(): void