From 2ef3ba44ec5b53fc85b52eea6309809f7c838a60 Mon Sep 17 00:00:00 2001 From: gabrielzigo Date: Wed, 22 Jul 2026 11:39:23 +0200 Subject: [PATCH 1/8] Add opt-in MCP server infrastructure with diagnostic log tools Opt-in "mcp" config section (disabled by default) built on symfony/mcp-bundle: streamable HTTP controller with DNS-rebinding protection and per-user rate limiting, strict tool-arguments handler, tool executor with configurable tool error exceptions and mongo call logging into a capped mcpLogs collection, and three diagnostic tools (search_app_logs, search_audit_logs, get_logs_by_context) reading the bundle-owned log collections. Log search queries live on the Journal/Audit log repositories via a new AbstractLogRepository. McpCompilerPass overrides the McpBundle controller and session cache after extension merge. The mcpLogs mongo connection defaults to the journal log connection. --- README.md | 1 + composer.json | 10 + src/AnzuSystemsCommonBundle.php | 2 + .../AnzuSystemsCommonExtension.php | 142 +++++++++++ .../CompilerPass/McpCompilerPass.php | 35 +++ src/DependencyInjection/Configuration.php | 62 +++++ src/Log/Repository/AbstractLogRepository.php | 83 +++++++ src/Log/Repository/AuditLogRepository.php | 57 ++++- src/Log/Repository/JournalLogRepository.php | 40 ++- .../Command/CreateMcpLogCollectionCommand.php | 99 ++++++++ src/Mcp/Controller/McpController.php | 61 +++++ src/Mcp/Exception/McpToolInputException.php | 11 + .../StrictToolArgumentsRequestHandler.php | 87 +++++++ src/Mcp/Log/McpLogFinder.php | 229 ++++++++++++++++++ src/Mcp/Log/McpLogRepository.php | 52 ++++ src/Mcp/Log/McpLogger.php | 50 ++++ src/Mcp/McpRateLimiter.php | 52 ++++ src/Mcp/McpToolExecutor.php | 117 +++++++++ src/Mcp/Model/McpAuditLogFilter.php | 20 ++ src/Mcp/Model/McpDateWindow.php | 16 ++ src/Mcp/Resolver/McpContextIdResolver.php | 36 +++ src/Mcp/Resolver/McpDateWindowResolver.php | 96 ++++++++ src/Mcp/Tool/GetLogsByContextTool.php | 64 +++++ src/Mcp/Tool/SearchAppLogsTool.php | 78 ++++++ src/Mcp/Tool/SearchAuditLogsTool.php | 90 +++++++ src/Resources/config/mcp.php | 114 +++++++++ src/Resources/doc/mcp.md | 69 ++++++ .../CompilerPass/McpCompilerPassTest.php | 52 ++++ tests/Mcp/McpRateLimiterTest.php | 76 ++++++ tests/Mcp/McpToolExecutorTest.php | 126 ++++++++++ .../Mcp/Resolver/McpContextIdResolverTest.php | 46 ++++ .../Resolver/McpDateWindowResolverTest.php | 80 ++++++ 32 files changed, 2135 insertions(+), 18 deletions(-) create mode 100644 src/DependencyInjection/CompilerPass/McpCompilerPass.php create mode 100644 src/Log/Repository/AbstractLogRepository.php create mode 100644 src/Mcp/Command/CreateMcpLogCollectionCommand.php create mode 100644 src/Mcp/Controller/McpController.php create mode 100644 src/Mcp/Exception/McpToolInputException.php create mode 100644 src/Mcp/Handler/StrictToolArgumentsRequestHandler.php create mode 100644 src/Mcp/Log/McpLogFinder.php create mode 100644 src/Mcp/Log/McpLogRepository.php create mode 100644 src/Mcp/Log/McpLogger.php create mode 100644 src/Mcp/McpRateLimiter.php create mode 100644 src/Mcp/McpToolExecutor.php create mode 100644 src/Mcp/Model/McpAuditLogFilter.php create mode 100644 src/Mcp/Model/McpDateWindow.php create mode 100644 src/Mcp/Resolver/McpContextIdResolver.php create mode 100644 src/Mcp/Resolver/McpDateWindowResolver.php create mode 100644 src/Mcp/Tool/GetLogsByContextTool.php create mode 100644 src/Mcp/Tool/SearchAppLogsTool.php create mode 100644 src/Mcp/Tool/SearchAuditLogsTool.php create mode 100644 src/Resources/config/mcp.php create mode 100644 src/Resources/doc/mcp.md create mode 100644 tests/DependencyInjection/CompilerPass/McpCompilerPassTest.php create mode 100644 tests/Mcp/McpRateLimiterTest.php create mode 100644 tests/Mcp/McpToolExecutorTest.php create mode 100644 tests/Mcp/Resolver/McpContextIdResolverTest.php create mode 100644 tests/Mcp/Resolver/McpDateWindowResolverTest.php diff --git a/README.md b/README.md index 61a174a..d54f3e4 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ common-bundle provides many functionalities, you can read about them in followin * [Helpers](src/Resources/doc/helpers.md) * [Locks](src/Resources/doc/locks.md) * [Logs](src/Resources/doc/logs.md) +* [MCP Server](src/Resources/doc/mcp.md) * [Param Converters (deprecated)](src/Resources/doc/param_converters.md) * [Proxy Cache](src/Resources/doc/proxy_cache.md) * [Tests](src/Resources/doc/tests.md) diff --git a/composer.json b/composer.json index 5851815..2f8145f 100644 --- a/composer.json +++ b/composer.json @@ -52,10 +52,20 @@ "jetbrains/phpstorm-attributes": "^1.0", "phpunit/phpunit": "^9.5", "slevomat/coding-standard": "8.20", + "symfony/mcp-bundle": "^0.10", + "symfony/rate-limiter": "^7.0|^8.0", "symfony/test-pack": "^1.0", "symplify/easy-coding-standard": "^13.0", "vimeo/psalm": "^6.10" }, + "suggest": { + "psr/simple-cache": "Required by the \"mcp\" config section (^3.0)", + "symfony/mcp-bundle": "Required by the \"mcp\" config section (^0.10)", + "symfony/rate-limiter": "Required by the \"mcp\" config section (^7.0|^8.0)" + }, + "conflict": { + "symfony/mcp-bundle": ">=0.11" + }, "autoload": { "psr-4": { "AnzuSystems\\CommonBundle\\": "src" diff --git a/src/AnzuSystemsCommonBundle.php b/src/AnzuSystemsCommonBundle.php index ab5185d..503d8e5 100644 --- a/src/AnzuSystemsCommonBundle.php +++ b/src/AnzuSystemsCommonBundle.php @@ -7,6 +7,7 @@ use AnzuSystems\CommonBundle\DependencyInjection\AnzuSystemsCommonExtension; use AnzuSystems\CommonBundle\DependencyInjection\CompilerPass\ExceptionHandlerCompilerPass; use AnzuSystems\CommonBundle\DependencyInjection\CompilerPass\HealthCheckModuleCompilerPass; +use AnzuSystems\CommonBundle\DependencyInjection\CompilerPass\McpCompilerPass; use AnzuSystems\SerializerBundle\AnzuSystemsSerializerBundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -24,5 +25,6 @@ public function build(ContainerBuilder $container): void $container->registerExtension(new AnzuSystemsCommonExtension()); $container->addCompilerPass(new ExceptionHandlerCompilerPass()); $container->addCompilerPass(new HealthCheckModuleCompilerPass()); + $container->addCompilerPass(new McpCompilerPass()); } } diff --git a/src/DependencyInjection/AnzuSystemsCommonExtension.php b/src/DependencyInjection/AnzuSystemsCommonExtension.php index 8e0e24d..8f485be 100644 --- a/src/DependencyInjection/AnzuSystemsCommonExtension.php +++ b/src/DependencyInjection/AnzuSystemsCommonExtension.php @@ -34,6 +34,7 @@ use AnzuSystems\CommonBundle\Controller\LogController; use AnzuSystems\CommonBundle\Controller\PermissionController; use AnzuSystems\CommonBundle\DataFixtures\Interfaces\FixturesInterface; +use AnzuSystems\CommonBundle\DependencyInjection\CompilerPass\McpCompilerPass; use AnzuSystems\CommonBundle\Doctrine\Query\AST\DateTime\Year; use AnzuSystems\CommonBundle\Doctrine\Query\AST\Numeric\Rand; use AnzuSystems\CommonBundle\Doctrine\Query\AST\String\Field; @@ -68,6 +69,9 @@ use AnzuSystems\CommonBundle\Log\LogFacade; use AnzuSystems\CommonBundle\Log\Repository\AuditLogRepository; use AnzuSystems\CommonBundle\Log\Repository\JournalLogRepository; +use AnzuSystems\CommonBundle\Mcp\Command\CreateMcpLogCollectionCommand; +use AnzuSystems\CommonBundle\Mcp\Controller\McpController; +use AnzuSystems\CommonBundle\Mcp\McpToolExecutor; use AnzuSystems\CommonBundle\Messenger\Message\AuditLogMessage; use AnzuSystems\CommonBundle\Messenger\Message\JournalLogMessage; use AnzuSystems\CommonBundle\Request\ParamConverter\ApiFilterParamConverter; @@ -90,8 +94,10 @@ use AnzuSystems\SerializerBundle\Serializer; use Doctrine\ORM\EntityManagerInterface; use Exception; +use LogicException; use MongoDB; use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface; +use Symfony\AI\McpBundle\McpBundle; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\Argument\IteratorArgument; @@ -105,9 +111,13 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\RateLimiter\RateLimiterFactory; +use Symfony\Component\RateLimiter\Storage\CacheStorage; final class AnzuSystemsCommonExtension extends Extension implements PrependExtensionInterface { + private const string MCP_TOOL_SCAN_DIR = 'vendor/anzusystems/common-bundle/src/Mcp/Tool'; + private array $processedConfig; public function prepend(ContainerBuilder $container): void @@ -117,6 +127,8 @@ public function prepend(ContainerBuilder $container): void $container->getExtensionConfig($this->getAlias()) ); + $this->prependMcp($container); + $container->prependExtensionConfig('doctrine', [ 'orm' => [ 'dql' => [ @@ -207,6 +219,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->loadHealthCheck($container); $this->loadErrors($container); $this->loadLogs($loader, $container); + $this->loadMcp($loader, $container); $this->loadAnzuSerializer($container); $this->loadPermissions($container); $this->loadValueResolvers($container); @@ -537,6 +550,135 @@ private function loadLogs(LoaderInterface $loader, ContainerBuilder $container): $container->setDefinition(LogController::class, $definition); } + private function prependMcp(ContainerBuilder $container): void + { + $mcp = $this->processedConfig['mcp']; + if (false === $mcp['enabled']) { + return; + } + if (false === class_exists(McpBundle::class)) { + return; + } + + $container->prependExtensionConfig('monolog', [ + 'channels' => ['mcp'], + ]); + $container->prependExtensionConfig('mcp', [ + 'discovery' => [ + 'scan_dirs' => [self::MCP_TOOL_SCAN_DIR], + ], + ]); + } + + private function loadMcp(LoaderInterface $loader, ContainerBuilder $container): void + { + $mcp = $this->processedConfig['mcp']; + if (false === $mcp['enabled']) { + return; + } + if (false === class_exists(McpBundle::class)) { + throw new LogicException('The "mcp" config section requires the "symfony/mcp-bundle" package.'); + } + if (false === $container->hasExtension('mcp')) { + throw new LogicException('The "mcp" config section requires McpBundle to be registered in bundles.php.'); + } + if (false === class_exists(RateLimiterFactory::class)) { + throw new LogicException('The "mcp" config section requires the "symfony/rate-limiter" package.'); + } + if (false === $this->processedConfig['logs']['enabled']) { + throw new LogicException('The "mcp" config section requires the "logs" config section to be enabled.'); + } + if ([] === $mcp['allowed_hosts']) { + throw new LogicException('The "mcp" config section requires non-empty "allowed_hosts", otherwise every request is rejected with 403.'); + } + + $loader->load('mcp.php'); + + $mongo = $this->resolveMcpLogMongo(); + $clientDefinition = new Definition(MongoDB\Client::class); + $clientDefinition->setArgument('$uri', $mongo['uri']); + $clientDefinition->setArgument('$uriOptions', [ + 'username' => $mongo['username'], + 'password' => $mongo['password'], + 'ssl' => $mongo['ssl'], + ]); + $container->setDefinition('anzu_mongo_mcp_log_client', $clientDefinition); + $container->registerAliasForArgument('anzu_mongo_mcp_log_client', MongoDB\Client::class, '$mcpLogClient'); + + $databaseDefinition = new Definition(MongoDB\Database::class); + $databaseDefinition->setFactory([new Reference('anzu_mongo_mcp_log_client'), 'selectDatabase']); + $databaseDefinition->setArgument('$databaseName', $mongo['database']); + $container->setDefinition('anzu_mongo_mcp_log_database', $databaseDefinition); + $container->registerAliasForArgument('anzu_mongo_mcp_log_database', MongoDB\Database::class, '$mcpLogDatabase'); + + $collectionDefinition = new Definition(MongoDB\Collection::class); + $collectionDefinition->setFactory([new Reference('anzu_mongo_mcp_log_client'), 'selectCollection']); + $collectionDefinition->setArgument('$databaseName', $mongo['database']); + $collectionDefinition->setArgument('$collectionName', $mongo['collection']); + $container->setDefinition('anzu_mongo_mcp_log_collection', $collectionDefinition); + $container->registerAliasForArgument('anzu_mongo_mcp_log_collection', MongoDB\Collection::class, '$mcpLogCollection'); + + $container->setParameter(McpCompilerPass::SESSION_CACHE_POOL_PARAM, $mcp['session']['cache_pool']); + + $rateLimiterStorageDefinition = new Definition(CacheStorage::class); + $rateLimiterStorageDefinition->setArgument('$pool', new Reference($mcp['rate_limiter']['cache_pool'])); + $container->setDefinition('anzu_systems_common.mcp.rate_limiter_storage', $rateLimiterStorageDefinition); + + $rateLimiterFactoryDefinition = new Definition(RateLimiterFactory::class); + $rateLimiterFactoryDefinition->setArgument('$config', [ + 'id' => 'mcp', + 'policy' => 'sliding_window', + 'limit' => $mcp['rate_limiter']['limit'], + 'interval' => $mcp['rate_limiter']['interval'], + ]); + $rateLimiterFactoryDefinition->setArgument('$storage', new Reference('anzu_systems_common.mcp.rate_limiter_storage')); + $container->setDefinition('anzu_systems_common.mcp.rate_limiter_factory', $rateLimiterFactoryDefinition); + + $container + ->getDefinition(McpController::class) + ->replaceArgument('$allowedHosts', $mcp['allowed_hosts']); + + $container + ->getDefinition(McpToolExecutor::class) + ->replaceArgument('$toolErrorExceptions', $mcp['tool_error_exceptions']); + + $container + ->getDefinition(CreateMcpLogCollectionCommand::class) + ->replaceArgument('$mcpLogCollectionName', $mongo['collection']) + ->replaceArgument('$mcpLogCollectionSizeMb', $mongo['size_mb']); + + $this->addMcpLogCollectionToHealthCheck($container, $mcp); + } + + private function addMcpLogCollectionToHealthCheck(ContainerBuilder $container, array $mcp): void + { + if (false === $mcp['logs']['add_to_health_check']) { + return; + } + if (false === $container->hasDefinition(MongoModule::class)) { + throw new LogicException('The "mcp.logs.add_to_health_check" option requires the "health_check" config section with the MongoModule enabled.'); + } + + $definition = $container->getDefinition(MongoModule::class); + /** @var IteratorArgument $collections */ + $collections = $definition->getArgument('$collections'); + $definition->replaceArgument('$collections', new IteratorArgument([ + ...$collections->getValues(), + new Reference('anzu_mongo_mcp_log_collection'), + ])); + } + + private function resolveMcpLogMongo(): array + { + $mongo = $this->processedConfig['mcp']['logs']['mongo']; + $journalMongo = $this->processedConfig['logs']['journal']['mongo'] ?? []; + foreach (['uri', 'username', 'password', 'database', 'ssl'] as $option) { + $mongo[$option] ??= $journalMongo[$option] ?? null; + } + + return $mongo; + } + private function loadAnzuSerializer(ContainerBuilder $container): void { $container->setDefinition( diff --git a/src/DependencyInjection/CompilerPass/McpCompilerPass.php b/src/DependencyInjection/CompilerPass/McpCompilerPass.php new file mode 100644 index 0000000..fdd2c20 --- /dev/null +++ b/src/DependencyInjection/CompilerPass/McpCompilerPass.php @@ -0,0 +1,35 @@ +hasDefinition(McpController::class)) { + return; + } + + $container + ->setAlias(self::MCP_SERVER_CONTROLLER_ID, McpController::class) + ->setPublic(true); + + $sessionCacheDefinition = new Definition(Psr16Cache::class); + $sessionCacheDefinition->setArgument('$pool', new Reference((string) $container->getParameter(self::SESSION_CACHE_POOL_PARAM))); + $container->setDefinition(self::MCP_SESSION_CACHE_ID, $sessionCacheDefinition); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 3364286..0f55572 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -37,6 +37,7 @@ use AnzuSystems\CommonBundle\HealthCheck\Module\MysqlModule; use AnzuSystems\CommonBundle\HealthCheck\Module\OpCacheModule; use AnzuSystems\CommonBundle\HealthCheck\Module\RedisModule; +use AnzuSystems\CommonBundle\Mcp\Log\McpLogger; use AnzuSystems\CommonBundle\Security\PermissionConfig; use AnzuSystems\CommonBundle\Serializer\Exception\SerializerExceptionHandler; use AnzuSystems\Contracts\Entity\AnzuUser; @@ -49,6 +50,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Messenger\Command\ConsumeMessagesCommand; +use Throwable; final class Configuration implements ConfigurationInterface { @@ -119,6 +121,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->append($this->addSettingsSection()) ->append($this->addErrorsSection()) ->append($this->addLogSection()) + ->append($this->addMcpSection()) ->append($this->addHealthCheckSection()) ->append($this->addPermissionsSection()) ->append($this->addJobsSection()) @@ -335,6 +338,65 @@ private function addLogSection(): NodeDefinition ; } + private function addMcpSection(): NodeDefinition + { + return (new TreeBuilder('mcp'))->getRootNode() + ->addDefaultsIfNotSet() + ->canBeEnabled() + ->children() + ->variableNode('allowed_hosts')->defaultValue([])->end() + ->arrayNode('tool_error_exceptions') + ->useAttributeAsKey('name') + ->validate() + ->ifTrue(static function (array $exceptions): bool { + foreach (array_keys($exceptions) as $exceptionClass) { + if (false === is_a($exceptionClass, Throwable::class, true)) { + return true; + } + } + + return false; + }) + ->thenInvalid('Invalid tool_error_exceptions "%s".') + ->end() + ->scalarPrototype()->end() + ->end() + ->arrayNode('rate_limiter') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('limit')->defaultValue(120)->end() + ->scalarNode('interval')->defaultValue('1 minute')->end() + ->scalarNode('cache_pool')->defaultValue('cache.app')->end() + ->end() + ->end() + ->arrayNode('session') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('cache_pool')->defaultValue('cache.app')->end() + ->end() + ->end() + ->arrayNode('logs') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('mongo') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('uri')->defaultNull()->end() + ->scalarNode('username')->defaultNull()->end() + ->scalarNode('password')->defaultNull()->end() + ->scalarNode('database')->defaultNull()->end() + ->scalarNode('ssl')->defaultNull()->end() + ->scalarNode('collection')->defaultValue(McpLogger::COLLECTION_NAME)->end() + ->scalarNode('size_mb')->defaultValue(200)->end() + ->end() + ->end() + ->booleanNode('add_to_health_check')->defaultFalse()->end() + ->end() + ->end() + ->end() + ; + } + private function addMongoConnectionSubSection(string $collection): NodeDefinition { return (new TreeBuilder('mongo'))->getRootNode() diff --git a/src/Log/Repository/AbstractLogRepository.php b/src/Log/Repository/AbstractLogRepository.php new file mode 100644 index 0000000..9a26886 --- /dev/null +++ b/src/Log/Repository/AbstractLogRepository.php @@ -0,0 +1,83 @@ + + */ +abstract class AbstractLogRepository extends AbstractAnzuMongoRepository +{ + protected const string FIELD_DATETIME = 'datetime'; + protected const string FIELD_CONTEXT_CONTEXT_ID = 'context.contextId'; + protected const string REGEX_FLAG_CASE_INSENSITIVE = 'i'; + protected const string MONGO_GTE = '$gte'; + protected const string MONGO_LTE = '$lte'; + protected const string MONGO_NE = '$ne'; + protected const string MONGO_OR = '$or'; + protected const string MONGO_EXISTS = '$exists'; + + private const int LIMIT_MIN = 1; + private const int SORT_DESC = -1; + private const array RAW_ARRAY_TYPE_MAP = [ + 'root' => 'array', + 'document' => 'array', + 'array' => 'array', + ]; + + /** + * @return list> + */ + public function findLatestByContextId(string $contextId, DateTimeImmutable $from, int $limit): array + { + return $this->findLatestRawDocuments([ + self::FIELD_CONTEXT_CONTEXT_ID => $contextId, + self::FIELD_DATETIME => [ + self::MONGO_GTE => new UTCDateTime($from), + ], + ], $limit); + } + + protected function getDocumentClass(): string + { + return Log::class; + } + + /** + * @param array $match + * + * @return list> + */ + protected function findLatestRawDocuments(array $match, int $limit): array + { + $documents = $this->collection->find($match, [ + 'sort' => [ + self::FIELD_DATETIME => self::SORT_DESC, + ], + 'limit' => max(self::LIMIT_MIN, $limit), + 'maxTimeMS' => $this->queryMaxTimeMs, + 'typeMap' => self::RAW_ARRAY_TYPE_MAP, + ]); + + return array_values($documents->toArray()); + } + + /** + * @return array + */ + protected function createDatetimeWindowMatch(DateTimeImmutable $from, DateTimeImmutable $until): array + { + return [ + self::FIELD_DATETIME => [ + self::MONGO_GTE => new UTCDateTime($from), + self::MONGO_LTE => new UTCDateTime($until), + ], + ]; + } +} diff --git a/src/Log/Repository/AuditLogRepository.php b/src/Log/Repository/AuditLogRepository.php index 1a935d6..77cb6bf 100644 --- a/src/Log/Repository/AuditLogRepository.php +++ b/src/Log/Repository/AuditLogRepository.php @@ -5,17 +5,24 @@ namespace AnzuSystems\CommonBundle\Log\Repository; use AnzuSystems\CommonBundle\ApiFilter\ApiQueryMongo; -use AnzuSystems\CommonBundle\Document\Log; -use AnzuSystems\CommonBundle\Repository\Mongo\AbstractAnzuMongoRepository; +use AnzuSystems\CommonBundle\Helper\StringHelper; use AnzuSystems\CommonBundle\Serializer\Service\BsonConverter; use AnzuSystems\SerializerBundle\Serializer; +use DateTimeImmutable; +use MongoDB\BSON\Regex; use MongoDB\Collection as MongoCollection; -/** - * @extends AbstractAnzuMongoRepository - */ -final class AuditLogRepository extends AbstractAnzuMongoRepository +final class AuditLogRepository extends AbstractLogRepository { + private const int ERROR_HTTP_STATUS_MIN = 400; + private const string FIELD_CONTEXT_USER_ID = 'context.userId'; + private const string FIELD_CONTEXT_PATH = 'context.path'; + private const string FIELD_CONTEXT_RESOURCE_NAME = 'context.resourceName'; + private const string FIELD_CONTEXT_HTTP_STATUS = 'context.httpStatus'; + private const string FIELD_CONTEXT_ERROR = 'context.error'; + private const string FIELD_CONTEXT_EXCEPTION = 'context.exception'; + private const string EMPTY_STRING = ''; + public function __construct( MongoCollection $auditLogCollection, Serializer $serializer, @@ -25,8 +32,40 @@ public function __construct( parent::__construct($auditLogCollection, $serializer, $this->bsonConverter, $queryMaxTimeMs); } - protected function getDocumentClass(): string - { - return Log::class; + /** + * @return list> + */ + public function findLatest( + DateTimeImmutable $from, + DateTimeImmutable $until, + ?int $userId, + ?string $pathContains, + ?string $resourceName, + ?string $contextId, + bool $onlyErrors, + int $limit, + ): array { + $match = $this->createDatetimeWindowMatch($from, $until); + if (is_int($userId)) { + $match[self::FIELD_CONTEXT_USER_ID] = $userId; + } + if (is_string($pathContains) && StringHelper::isNotEmpty($pathContains)) { + $match[self::FIELD_CONTEXT_PATH] = new Regex(preg_quote($pathContains), self::REGEX_FLAG_CASE_INSENSITIVE); + } + if (is_string($resourceName) && StringHelper::isNotEmpty($resourceName)) { + $match[self::FIELD_CONTEXT_RESOURCE_NAME] = $resourceName; + } + if (is_string($contextId) && StringHelper::isNotEmpty($contextId)) { + $match[self::FIELD_CONTEXT_CONTEXT_ID] = $contextId; + } + if ($onlyErrors) { + $match[self::MONGO_OR] = [ + [self::FIELD_CONTEXT_HTTP_STATUS => [self::MONGO_GTE => self::ERROR_HTTP_STATUS_MIN]], + [self::FIELD_CONTEXT_ERROR => [self::MONGO_EXISTS => true, self::MONGO_NE => self::EMPTY_STRING]], + [self::FIELD_CONTEXT_EXCEPTION => [self::MONGO_EXISTS => true, self::MONGO_NE => self::EMPTY_STRING]], + ]; + } + + return $this->findLatestRawDocuments($match, $limit); } } diff --git a/src/Log/Repository/JournalLogRepository.php b/src/Log/Repository/JournalLogRepository.php index 6bafe05..c6c6661 100644 --- a/src/Log/Repository/JournalLogRepository.php +++ b/src/Log/Repository/JournalLogRepository.php @@ -5,17 +5,19 @@ namespace AnzuSystems\CommonBundle\Log\Repository; use AnzuSystems\CommonBundle\ApiFilter\ApiQueryMongo; -use AnzuSystems\CommonBundle\Document\Log; -use AnzuSystems\CommonBundle\Repository\Mongo\AbstractAnzuMongoRepository; +use AnzuSystems\CommonBundle\Helper\StringHelper; use AnzuSystems\CommonBundle\Serializer\Service\BsonConverter; use AnzuSystems\SerializerBundle\Serializer; +use DateTimeImmutable; +use MongoDB\BSON\Regex; use MongoDB\Collection as MongoCollection; -/** - * @extends AbstractAnzuMongoRepository - */ -final class JournalLogRepository extends AbstractAnzuMongoRepository +final class JournalLogRepository extends AbstractLogRepository { + public const string FIELD_LEVEL_NAME = 'level_name'; + + private const string FIELD_MESSAGE = 'message'; + public function __construct( MongoCollection $journalLogCollection, Serializer $serializer, @@ -25,8 +27,28 @@ public function __construct( parent::__construct($journalLogCollection, $serializer, $this->bsonConverter, $queryMaxTimeMs); } - protected function getDocumentClass(): string - { - return Log::class; + /** + * @return list> + */ + public function findLatest( + DateTimeImmutable $from, + DateTimeImmutable $until, + ?string $levelName, + ?string $messageContains, + ?string $contextId, + int $limit, + ): array { + $match = $this->createDatetimeWindowMatch($from, $until); + if (is_string($levelName) && StringHelper::isNotEmpty($levelName)) { + $match[self::FIELD_LEVEL_NAME] = $levelName; + } + if (is_string($messageContains) && StringHelper::isNotEmpty($messageContains)) { + $match[self::FIELD_MESSAGE] = new Regex(preg_quote($messageContains), self::REGEX_FLAG_CASE_INSENSITIVE); + } + if (is_string($contextId) && StringHelper::isNotEmpty($contextId)) { + $match[self::FIELD_CONTEXT_CONTEXT_ID] = $contextId; + } + + return $this->findLatestRawDocuments($match, $limit); } } diff --git a/src/Mcp/Command/CreateMcpLogCollectionCommand.php b/src/Mcp/Command/CreateMcpLogCollectionCommand.php new file mode 100644 index 0000000..c43cf37 --- /dev/null +++ b/src/Mcp/Command/CreateMcpLogCollectionCommand.php @@ -0,0 +1,99 @@ +findCollectionOptions(); + if (null === $options) { + return $this->createCollection($io); + } + + return $this->reportExistingCollection($io, $options); + } + + private function createCollection(SymfonyStyle $io): int + { + try { + $this->mcpLogDatabase->createCollection($this->mcpLogCollectionName, [ + self::CAPPED_OPTION => true, + 'size' => $this->mcpLogCollectionSizeMb * self::BYTES_PER_MEGABYTE, + ]); + } catch (CommandException $exception) { + return $this->resolveCreateRace($io, $exception); + } + $io->writeln(sprintf( + 'Created capped collection "%s" (%d MB).', + $this->mcpLogCollectionName, + $this->mcpLogCollectionSizeMb, + )); + + return Command::SUCCESS; + } + + private function resolveCreateRace(SymfonyStyle $io, CommandException $exception): int + { + if (self::NAMESPACE_EXISTS_ERROR_CODE === $exception->getCode()) { + return $this->reportExistingCollection($io, $this->findCollectionOptions() ?? []); + } + + throw $exception; + } + + /** + * @param array $options + */ + private function reportExistingCollection(SymfonyStyle $io, array $options): int + { + if (true === ($options[self::CAPPED_OPTION] ?? false)) { + $io->writeln(sprintf('Capped collection "%s" already exists.', $this->mcpLogCollectionName)); + + return Command::SUCCESS; + } + $io->error(sprintf('Collection "%s" exists but is not capped, convert it manually.', $this->mcpLogCollectionName)); + + return Command::FAILURE; + } + + /** + * @return array|null + */ + private function findCollectionOptions(): ?array + { + foreach ($this->mcpLogDatabase->listCollections([ + 'filter' => [ + 'name' => $this->mcpLogCollectionName, + ], + ]) as $collectionInfo) { + return $collectionInfo->getOptions(); + } + + return null; + } +} diff --git a/src/Mcp/Controller/McpController.php b/src/Mcp/Controller/McpController.php new file mode 100644 index 0000000..0b93b3a --- /dev/null +++ b/src/Mcp/Controller/McpController.php @@ -0,0 +1,61 @@ + $allowedHosts + */ + public function __construct( + private Server $server, + private HttpMessageFactoryInterface $httpMessageFactory, + private HttpFoundationFactoryInterface $httpFoundationFactory, + private ResponseFactoryInterface $responseFactory, + private StreamFactoryInterface $streamFactory, + private McpRateLimiter $rateLimiter, + private LoggerInterface $logger, + private array $allowedHosts, + ) { + } + + public function handle(Request $request): Response + { + AuditLogResourceHelper::excludeFromAuditLogs($request); + $this->rateLimiter->checkRateLimit(); + + $transport = new StreamableHttpTransport( + $this->httpMessageFactory->createRequest($request), + $this->responseFactory, + $this->streamFactory, + $this->logger, + [ + new DnsRebindingProtectionMiddleware($this->allowedHosts), + new ProtocolVersionMiddleware(), + ], + ); + + $psrResponse = $this->server->run($transport); + $streamed = str_starts_with(strtolower($psrResponse->getHeaderLine('Content-Type')), self::STREAMED_CONTENT_TYPE); + + return $this->httpFoundationFactory->createResponse($psrResponse, $streamed); + } +} diff --git a/src/Mcp/Exception/McpToolInputException.php b/src/Mcp/Exception/McpToolInputException.php new file mode 100644 index 0000000..f54187e --- /dev/null +++ b/src/Mcp/Exception/McpToolInputException.php @@ -0,0 +1,11 @@ + + */ +final readonly class StrictToolArgumentsRequestHandler implements RequestHandlerInterface +{ + public function __construct( + private RegistryInterface $registry, + private LoggerInterface $logger, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof CallToolRequest + && false === empty($this->findUnknownArguments($request)); + } + + public function handle(Request $request, SessionInterface $session): Response|Error + { + if (false === $request instanceof CallToolRequest) { + return Error::forInternalError('Unsupported request.', $request->getId()); + } + + $unknownArguments = $this->findUnknownArguments($request); + $message = sprintf( + 'Unknown argument%s "%s". Allowed arguments: %s.', + 1 === count($unknownArguments) ? '' : 's', + implode('", "', $unknownArguments), + implode(', ', $this->getAllowedArguments($this->registry->getTool($request->name))), + ); + $this->logger->info('Mcp tool call rejected.', [ + 'tool' => $request->name, + 'unknownArguments' => $unknownArguments, + ]); + $payload = [McpToolExecutor::ERROR_KEY => $message]; + + return new Response( + $request->getId(), + new CallToolResult(new ToolResultFormatter()->format($payload), structuredContent: $payload), + ); + } + + /** + * @return list + */ + private function findUnknownArguments(CallToolRequest $request): array + { + try { + $reference = $this->registry->getTool($request->name); + } catch (ToolNotFoundException) { + return []; + } + + return array_values(array_diff( + array_map(strval(...), array_keys($request->arguments)), + $this->getAllowedArguments($reference), + )); + } + + /** + * @return list + */ + private function getAllowedArguments(ToolReference $reference): array + { + return array_map(strval(...), array_keys((array) ($reference->tool->inputSchema['properties'] ?? []))); + } +} diff --git a/src/Mcp/Log/McpLogFinder.php b/src/Mcp/Log/McpLogFinder.php new file mode 100644 index 0000000..635cd9a --- /dev/null +++ b/src/Mcp/Log/McpLogFinder.php @@ -0,0 +1,229 @@ +> + */ + public function findAuditLogs(McpAuditLogFilter $filter): array + { + $window = $this->dateWindowResolver->resolveLogWindow($filter->from, $filter->until); + $documents = $this->auditLogRepository->findLatest( + from: $window->from, + until: $window->until, + userId: $filter->userId, + pathContains: $filter->pathContains, + resourceName: $filter->resourceName, + contextId: $filter->contextId, + onlyErrors: $filter->onlyErrors, + limit: $this->clampLimit($filter->limit), + ); + + return array_map($this->mapAuditLog(...), $documents); + } + + /** + * @return list> + */ + public function findAppLogs( + ?string $level, + ?string $messageContains, + ?string $contextId, + ?string $from, + ?string $until, + int $limit, + ): array { + $window = $this->dateWindowResolver->resolveLogWindow($from, $until); + $documents = $this->journalLogRepository->findLatest( + from: $window->from, + until: $window->until, + levelName: null === $level ? null : strtoupper($level), + messageContains: $messageContains, + contextId: $contextId, + limit: $this->clampLimit($limit), + ); + + return array_map($this->mapAppLog(...), $documents); + } + + /** + * @return list> + */ + public function findAuditLogsByContextId(string $contextId): array + { + $documents = $this->auditLogRepository->findLatestByContextId($contextId, $this->createByContextFrom(), self::LIMIT_MAX); + + return array_map($this->mapAuditLog(...), $documents); + } + + /** + * @return list> + */ + public function findAppLogsByContextId(string $contextId): array + { + $documents = $this->journalLogRepository->findLatestByContextId($contextId, $this->createByContextFrom(), self::LIMIT_MAX); + + return array_map($this->mapAppLog(...), $documents); + } + + /** + * @return list> + */ + public function findMcpLogsByContextId(string $contextId): array + { + $documents = $this->mcpLogRepository->findLatestByContextId($contextId, $this->createByContextFrom(), self::LIMIT_MAX); + + return array_map($this->mapMcpLog(...), $documents); + } + + private function createByContextFrom(): DateTimeImmutable + { + return AnzuApp::date()->modify(sprintf('-%d days', self::BY_CONTEXT_SCAN_DAYS)); + } + + private function clampLimit(int $limit): int + { + return min(max($limit, self::LIMIT_MIN), self::LIMIT_MAX); + } + + /** + * @param array $document + * + * @return array + */ + private function mapAuditLog(array $document): array + { + $context = $this->toArrayValue($document['context'] ?? []); + + return [ + 'datetime' => $this->formatDateTime($document['datetime'] ?? null), + 'method' => $this->toStringValue($context['method'] ?? null), + 'path' => $this->toStringValue($context['path'] ?? null), + 'resourceName' => $this->toStringValue($context['resourceName'] ?? null), + 'resourceIds' => array_values($this->toArrayValue($context['resourceIds'] ?? [])), + 'httpStatus' => $this->toIntValue($context['httpStatus'] ?? null), + 'error' => $this->truncate($this->toStringValue($context['error'] ?? null)), + 'exception' => $this->truncate($this->toStringValue($context['exception'] ?? null)), + 'contextId' => $this->toStringValue($context['contextId'] ?? null), + 'userId' => $this->toIntValue($context['userId'] ?? null), + 'content' => $this->truncate($this->toStringValue($context['content'] ?? null)), + 'response' => $this->truncate($this->toStringValue($context['response'] ?? null)), + ]; + } + + /** + * @param array $document + * + * @return array + */ + private function mapAppLog(array $document): array + { + $context = $this->toArrayValue($document['context'] ?? []); + + return [ + 'datetime' => $this->formatDateTime($document['datetime'] ?? null), + 'levelName' => $this->toStringValue($document[JournalLogRepository::FIELD_LEVEL_NAME] ?? null), + 'message' => $this->truncate($this->toStringValue($document['message'] ?? null)), + 'contextId' => $this->toStringValue($context['contextId'] ?? null), + 'userId' => $this->toIntValue($context['userId'] ?? null), + 'path' => $this->toStringValue($context['path'] ?? null), + ]; + } + + /** + * @param array $document + * + * @return array + */ + private function mapMcpLog(array $document): array + { + return [ + 'datetime' => $this->formatDateTime($document['datetime'] ?? null), + 'levelName' => $this->toStringValue($document['levelName'] ?? null), + 'tool' => $this->toStringValue($document['tool'] ?? null), + 'params' => $this->toArrayValue($document['params'] ?? []), + 'userId' => $this->toIntValue($document['userId'] ?? null), + 'contextId' => $this->toStringValue($document['contextId'] ?? null), + 'durationMs' => $this->toIntValue($document['durationMs'] ?? null), + 'error' => $this->truncate($this->toStringValue($document['error'] ?? null)), + ]; + } + + private function truncate(string $value): string + { + if (mb_strlen($value) <= self::FIELD_TRUNCATE_LENGTH) { + return $value; + } + + return mb_substr($value, 0, self::FIELD_TRUNCATE_LENGTH) . self::TRUNCATED_SUFFIX; + } + + private function formatDateTime(mixed $datetime): string + { + if ($datetime instanceof UTCDateTime) { + return $datetime->toDateTime()->format(DateTimeInterface::ATOM); + } + + return self::EMPTY_STRING; + } + + private function toStringValue(mixed $value): string + { + if (is_string($value)) { + return $value; + } + + return self::EMPTY_STRING; + } + + private function toIntValue(mixed $value): int + { + if (is_int($value)) { + return $value; + } + + return 0; + } + + /** + * @return array + */ + private function toArrayValue(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + return []; + } +} diff --git a/src/Mcp/Log/McpLogRepository.php b/src/Mcp/Log/McpLogRepository.php new file mode 100644 index 0000000..0870797 --- /dev/null +++ b/src/Mcp/Log/McpLogRepository.php @@ -0,0 +1,52 @@ + 'array', + 'document' => 'array', + 'array' => 'array', + ]; + + public function __construct( + private Collection $mcpLogCollection, + private int $queryMaxTimeMs = ApiQueryMongo::DEFAULT_QUERY_MAX_TIME_MS, + ) { + } + + /** + * @return list> + */ + public function findLatestByContextId(string $contextId, DateTimeImmutable $from, int $limit): array + { + $documents = $this->mcpLogCollection->find([ + self::FIELD_CONTEXT_ID => $contextId, + self::FIELD_DATETIME => [ + self::MONGO_GTE => new UTCDateTime($from), + ], + ], [ + 'sort' => [ + self::FIELD_DATETIME => self::SORT_DESC, + ], + 'limit' => max(self::LIMIT_MIN, $limit), + 'maxTimeMS' => $this->queryMaxTimeMs, + 'typeMap' => self::RAW_ARRAY_TYPE_MAP, + ]); + + return array_values($documents->toArray()); + } +} diff --git a/src/Mcp/Log/McpLogger.php b/src/Mcp/Log/McpLogger.php new file mode 100644 index 0000000..64442ed --- /dev/null +++ b/src/Mcp/Log/McpLogger.php @@ -0,0 +1,50 @@ + $params + */ + public function log(string $tool, array $params, ?int $userId, int $durationMs, ?string $error): void + { + $this->mcpLogCollection->insertOne([ + 'datetime' => new UTCDateTime(AnzuApp::date()), + 'levelName' => $this->resolveLevelName($error), + 'tool' => $tool, + 'params' => $params, + 'userId' => $userId, + 'contextId' => AnzuApp::getContextId(), + 'durationMs' => $durationMs, + 'error' => $error, + 'hint' => self::HINT, + ]); + } + + private function resolveLevelName(?string $error): string + { + if (null === $error) { + return self::LEVEL_NAME_INFO; + } + + return self::LEVEL_NAME_ERROR; + } +} diff --git a/src/Mcp/McpRateLimiter.php b/src/Mcp/McpRateLimiter.php new file mode 100644 index 0000000..5e16f22 --- /dev/null +++ b/src/Mcp/McpRateLimiter.php @@ -0,0 +1,52 @@ +currentUserProvider->getCurrentUser() + ->getId(); + if (AnzuApp::getUserIdAnonymous() === $userId) { + throw new AccessDeniedHttpException('Anonymous access to the MCP endpoint is not allowed.'); + } + + $limiter = $this->mcpLimiter->create((string) $userId); + $limit = $limiter->consume(); + if ($limit->isAccepted()) { + return; + } + + $retryAfter = $limit->getRetryAfter(); + + throw new TooManyRequestsHttpException( + $retryAfter->getTimestamp(), + 'Too many requests', + headers: [ + 'X-RateLimit-Limit' => (string) $limit->getLimit(), + 'X-RateLimit-Remaining' => (string) $limit->getRemainingTokens(), + 'X-RateLimit-Reset' => (string) $retryAfter->getTimestamp(), + 'Retry-After' => (string) max(0, $retryAfter->getTimestamp() - time()), + ], + ); + } +} diff --git a/src/Mcp/McpToolExecutor.php b/src/Mcp/McpToolExecutor.php new file mode 100644 index 0000000..403ec05 --- /dev/null +++ b/src/Mcp/McpToolExecutor.php @@ -0,0 +1,117 @@ +, string> $toolErrorExceptions + */ + public function __construct( + private CurrentAnzuUserProvider $currentUserProvider, + private LoggerInterface $logger, + private McpLogger $mcpLogger, + private array $toolErrorExceptions = [], + ) { + } + + /** + * @param array $params + * @param Closure(): array $callback + * + * @return array + */ + public function execute(string $toolName, array $params, Closure $callback): array + { + $startedAt = hrtime(true); + $error = null; + + try { + return $callback(); + } catch (McpToolInputException $exception) { + $error = $exception->getMessage(); + + return [self::ERROR_KEY => $error]; + } catch (AccessDeniedException) { + $error = 'Access denied — the current MCP user is not allowed to use the requested filters.'; + + return [self::ERROR_KEY => $error]; + } catch (Throwable $exception) { + $message = $this->resolveToolErrorMessage($exception); + if (null === $message) { + $error = sprintf('Unhandled %s', $exception::class); + + throw $exception; + } + $this->logger->error('Mcp tool call failed on backend.', [ + 'tool' => $toolName, + 'exception' => $exception, + ]); + $error = $message; + + return [self::ERROR_KEY => $error]; + } finally { + $userId = $this->resolveCurrentUserId(); + $durationMs = (int) round((hrtime(true) - $startedAt) / 1_000_000); + + $this->logger->info('Mcp tool call.', [ + 'tool' => $toolName, + 'userId' => $userId, + 'params' => $params, + 'error' => $error, + 'durationMs' => $durationMs, + ]); + $this->logToMongo($toolName, $params, $userId, $durationMs, $error); + } + } + + private function resolveCurrentUserId(): ?int + { + try { + return $this->currentUserProvider->getCurrentUser() + ->getId(); + } catch (Throwable) { + return null; + } + } + + private function resolveToolErrorMessage(Throwable $exception): ?string + { + foreach ($this->toolErrorExceptions as $exceptionClass => $message) { + if ($exception instanceof $exceptionClass) { + return $message; + } + } + + return null; + } + + /** + * @param array $params + */ + private function logToMongo(string $toolName, array $params, ?int $userId, int $durationMs, ?string $error): void + { + try { + $this->mcpLogger->log($toolName, $params, $userId, $durationMs, $error); + } catch (Throwable $exception) { + $this->logger->error('Mcp tool call mongo log failed.', [ + 'tool' => $toolName, + 'exception' => $exception, + ]); + } + } +} diff --git a/src/Mcp/Model/McpAuditLogFilter.php b/src/Mcp/Model/McpAuditLogFilter.php new file mode 100644 index 0000000..5ff3448 --- /dev/null +++ b/src/Mcp/Model/McpAuditLogFilter.php @@ -0,0 +1,20 @@ +toRfc4122(); + } + + throw new McpToolInputException( + sprintf('Invalid contextId "%s", provide a UUID taken from a log record.', $contextId), + ); + } + + public function resolveOptional(?string $contextId): ?string + { + if (null === $contextId) { + return null; + } + if (StringHelper::isEmpty(trim($contextId))) { + return null; + } + + return $this->resolve($contextId); + } +} diff --git a/src/Mcp/Resolver/McpDateWindowResolver.php b/src/Mcp/Resolver/McpDateWindowResolver.php new file mode 100644 index 0000000..4b9d857 --- /dev/null +++ b/src/Mcp/Resolver/McpDateWindowResolver.php @@ -0,0 +1,96 @@ +parseDateTime('publishedFrom', $publishedFrom); + $until = $this->parseDateTime('publishedUntil', $publishedUntil); + $clampedFrom = $this->clampFrom($from, $until); + + return new McpDateWindow($clampedFrom, $this->clampUntil($clampedFrom, $until)); + } + + public function resolveLogWindow(?string $from, ?string $until): McpDateWindow + { + $parsedFrom = $this->parseDateTime('from', $from); + $resolvedUntil = $this->parseDateTime('until', $until) ?? AnzuApp::date(); + $resolvedFrom = $parsedFrom ?? $resolvedUntil->modify(sprintf('-%d day', self::LOG_WINDOW_DEFAULT_DAYS)); + if ($resolvedUntil < $resolvedFrom) { + throw new McpToolInputException(self::ERROR_INVERTED_LOG_WINDOW); + } + + return new McpDateWindow($this->clampLogFrom($resolvedFrom, $resolvedUntil), $resolvedUntil); + } + + public function parseDateTime(string $paramName, ?string $value): ?DateTimeImmutable + { + if (null === $value) { + return null; + } + + try { + return new DateTimeImmutable($value); + } catch (Exception) { + throw new McpToolInputException( + sprintf('Invalid %s value "%s", provide an ISO 8601 date-time, e.g. "2026-07-09T06:00:00+02:00".', $paramName, $value), + ); + } + } + + public function ensureNotInverted(?DateTimeImmutable $from, ?DateTimeImmutable $until): void + { + if ($from instanceof DateTimeImmutable && $until instanceof DateTimeImmutable && $until < $from) { + throw new McpToolInputException(self::ERROR_INVERTED_DATE_WINDOW); + } + } + + private function clampLogFrom(DateTimeImmutable $from, DateTimeImmutable $until): DateTimeImmutable + { + $minFrom = $until->modify(sprintf('-%d days', self::LOG_WINDOW_MAX_DAYS)); + if ($from < $minFrom) { + return $minFrom; + } + + return $from; + } + + private function clampFrom(?DateTimeImmutable $from, ?DateTimeImmutable $until): DateTimeImmutable + { + if ($from instanceof DateTimeImmutable) { + return $from; + } + + return ($until ?? AnzuApp::date())->modify(sprintf('-%d days', self::DATE_RANGE_MAX_DAYS)); + } + + private function clampUntil(DateTimeImmutable $clampedFrom, ?DateTimeImmutable $until): DateTimeImmutable + { + $maxUntil = $clampedFrom->modify(sprintf('+%d days', self::DATE_RANGE_MAX_DAYS)); + if (null === $until || $until > $maxUntil) { + return $maxUntil; + } + if ($until < $clampedFrom) { + throw new McpToolInputException(self::ERROR_INVERTED_DATE_WINDOW); + } + + return $until; + } +} diff --git a/src/Mcp/Tool/GetLogsByContextTool.php b/src/Mcp/Tool/GetLogsByContextTool.php new file mode 100644 index 0000000..f94a90f --- /dev/null +++ b/src/Mcp/Tool/GetLogsByContextTool.php @@ -0,0 +1,64 @@ + + */ + #[Schema(additionalProperties: false)] + public function __invoke( + #[Schema(description: 'The contextId (UUID) of one request, taken from an audit log, application log, or MCP tool call record.')] + string $contextId, + ): array { + return $this->toolExecutor->execute( + self::NAME, + ['contextId' => $contextId], + fn (): array => $this->getLogsByContext($this->contextIdResolver->resolve($contextId)), + ); + } + + /** + * @return array + */ + private function getLogsByContext(string $contextId): array + { + return [ + 'auditLogs' => $this->logFinder->findAuditLogsByContextId($contextId), + 'appLogs' => $this->logFinder->findAppLogsByContextId($contextId), + 'mcpToolCalls' => $this->logFinder->findMcpLogsByContextId($contextId), + 'hint' => self::HINT_CROSS_SERVICE, + ]; + } +} diff --git a/src/Mcp/Tool/SearchAppLogsTool.php b/src/Mcp/Tool/SearchAppLogsTool.php new file mode 100644 index 0000000..67340c1 --- /dev/null +++ b/src/Mcp/Tool/SearchAppLogsTool.php @@ -0,0 +1,78 @@ + + */ + #[Schema(additionalProperties: false)] + public function __invoke( + #[Schema(description: 'Log level name, e.g. "ERROR", "WARNING", "INFO". Case-insensitive, exact match. Omit for all levels.')] + ?string $level = null, + #[Schema(description: 'Case-insensitive substring match on the log message.')] + ?string $messageContains = null, + #[Schema(description: 'Exact contextId (UUID) of one request, as found in other log records.')] + ?string $contextId = null, + #[Schema(description: 'Only records at or after this ISO 8601 date-time, e.g. "2026-07-20T06:00:00+02:00". Defaults to 1 day before until.')] + ?string $from = null, + #[Schema(description: 'Only records at or before this ISO 8601 date-time. Defaults to now; the from..until window is capped at 31 days.')] + ?string $until = null, + #[Schema(description: 'Maximum number of records, capped at 50.')] + int $limit = McpLogFinder::LIMIT_DEFAULT, + ): array { + return $this->toolExecutor->execute( + self::NAME, + [ + 'level' => $level, + 'messageContains' => $messageContains, + 'contextId' => $contextId, + 'from' => $from, + 'until' => $until, + 'limit' => $limit, + ], + fn (): array => [ + 'appLogs' => $this->logFinder->findAppLogs( + level: $level, + messageContains: $messageContains, + contextId: $this->contextIdResolver->resolveOptional($contextId), + from: $from, + until: $until, + limit: $limit, + ), + 'hint' => self::HINT_CONTEXT_ID, + ], + ); + } +} diff --git a/src/Mcp/Tool/SearchAuditLogsTool.php b/src/Mcp/Tool/SearchAuditLogsTool.php new file mode 100644 index 0000000..9707d3d --- /dev/null +++ b/src/Mcp/Tool/SearchAuditLogsTool.php @@ -0,0 +1,90 @@ + + */ + #[Schema(additionalProperties: false)] + public function __invoke( + #[Schema(description: 'Filter by the id of the user who made the request. Omit to search requests of all users.')] + ?int $userId = null, + #[Schema(description: 'When true (default), only failed requests are returned: http status 400 or higher, or a non-empty error/exception. Set to false to include successful requests.')] + bool $onlyErrors = true, + #[Schema(description: 'Case-insensitive substring match on the request path, e.g. "article".')] + ?string $pathContains = null, + #[Schema(description: 'Exact resource name of the affected entity as stored in the audit record, e.g. "articleKindStandard".')] + ?string $resourceName = null, + #[Schema(description: 'Exact contextId (UUID) of one request, as found in other log records.')] + ?string $contextId = null, + #[Schema(description: 'Only records at or after this ISO 8601 date-time, e.g. "2026-07-20T06:00:00+02:00". Defaults to 1 day before until.')] + ?string $from = null, + #[Schema(description: 'Only records at or before this ISO 8601 date-time. Defaults to now; the from..until window is capped at 31 days.')] + ?string $until = null, + #[Schema(description: 'Maximum number of records, capped at 50.')] + int $limit = McpLogFinder::LIMIT_DEFAULT, + ): array { + return $this->toolExecutor->execute( + self::NAME, + [ + 'userId' => $userId, + 'onlyErrors' => $onlyErrors, + 'pathContains' => $pathContains, + 'resourceName' => $resourceName, + 'contextId' => $contextId, + 'from' => $from, + 'until' => $until, + 'limit' => $limit, + ], + fn (): array => [ + 'auditLogs' => $this->logFinder->findAuditLogs(new McpAuditLogFilter( + userId: $userId, + onlyErrors: $onlyErrors, + pathContains: $pathContains, + resourceName: $resourceName, + contextId: $this->contextIdResolver->resolveOptional($contextId), + from: $from, + until: $until, + limit: $limit, + )), + 'hint' => self::HINT_CONTEXT_ID, + ], + ); + } +} diff --git a/src/Resources/config/mcp.php b/src/Resources/config/mcp.php new file mode 100644 index 0000000..ea991b4 --- /dev/null +++ b/src/Resources/config/mcp.php @@ -0,0 +1,114 @@ +services(); + + $services + ->defaults() + ->autowire(false) + ->autoconfigure(false) + ; + + $services->set(McpContextIdResolver::class); + + $services->set(McpDateWindowResolver::class); + + $services->set(StrictToolArgumentsRequestHandler::class) + ->arg('$registry', service('mcp.registry')) + ->arg('$logger', service('logger')) + ->tag('mcp.request_handler') + ->tag('monolog.logger', ['channel' => 'mcp']) + ; + + $services->set(McpLogger::class) + ->arg('$mcpLogCollection', service('anzu_mongo_mcp_log_collection')) + ; + + $services->set(McpLogRepository::class) + ->arg('$mcpLogCollection', service('anzu_mongo_mcp_log_collection')) + ->arg('$queryMaxTimeMs', param('anzu_systems_common.mongo_query_max_time_ms')) + ; + + $services->set(McpLogFinder::class) + ->arg('$auditLogRepository', service(AuditLogRepository::class)) + ->arg('$journalLogRepository', service(JournalLogRepository::class)) + ->arg('$mcpLogRepository', service(McpLogRepository::class)) + ->arg('$dateWindowResolver', service(McpDateWindowResolver::class)) + ; + + $services->set(McpToolExecutor::class) + ->arg('$currentUserProvider', service(CurrentAnzuUserProvider::class)) + ->arg('$logger', service('logger')) + ->arg('$mcpLogger', service(McpLogger::class)) + ->arg('$toolErrorExceptions', null) + ->tag('monolog.logger', ['channel' => 'mcp']) + ; + + $services->set(McpRateLimiter::class) + ->arg('$mcpLimiter', service('anzu_systems_common.mcp.rate_limiter_factory')) + ->arg('$currentUserProvider', service(CurrentAnzuUserProvider::class)) + ; + + $services->set(McpController::class) + ->arg('$server', service('mcp.server')) + ->arg('$httpMessageFactory', service('mcp.psr_http_factory')) + ->arg('$httpFoundationFactory', service('mcp.http_foundation_factory')) + ->arg('$responseFactory', service('mcp.psr17_factory')) + ->arg('$streamFactory', service('mcp.psr17_factory')) + ->arg('$rateLimiter', service(McpRateLimiter::class)) + ->arg('$logger', service('logger')) + ->arg('$allowedHosts', null) + ->tag('controller.service_arguments') + ->tag('monolog.logger', ['channel' => 'mcp']) + ->public() + ; + + $services->set(CreateMcpLogCollectionCommand::class) + ->arg('$mcpLogDatabase', service('anzu_mongo_mcp_log_database')) + ->arg('$mcpLogCollectionName', null) + ->arg('$mcpLogCollectionSizeMb', null) + ->tag('console.command') + ; + + $services->set(SearchAppLogsTool::class) + ->arg('$logFinder', service(McpLogFinder::class)) + ->arg('$contextIdResolver', service(McpContextIdResolver::class)) + ->arg('$toolExecutor', service(McpToolExecutor::class)) + ->tag('mcp.tool') + ; + + $services->set(SearchAuditLogsTool::class) + ->arg('$logFinder', service(McpLogFinder::class)) + ->arg('$contextIdResolver', service(McpContextIdResolver::class)) + ->arg('$toolExecutor', service(McpToolExecutor::class)) + ->tag('mcp.tool') + ; + + $services->set(GetLogsByContextTool::class) + ->arg('$logFinder', service(McpLogFinder::class)) + ->arg('$contextIdResolver', service(McpContextIdResolver::class)) + ->arg('$toolExecutor', service(McpToolExecutor::class)) + ->tag('mcp.tool') + ; +}; diff --git a/src/Resources/doc/mcp.md b/src/Resources/doc/mcp.md new file mode 100644 index 0000000..94d0c43 --- /dev/null +++ b/src/Resources/doc/mcp.md @@ -0,0 +1,69 @@ +# MCP Server + +Opt-in MCP (Model Context Protocol) server infrastructure built on [symfony/mcp-bundle](https://github.com/symfony/mcp-bundle), +including three diagnostic log tools (`search_app_logs`, `search_audit_logs`, `get_logs_by_context`) that read the +bundle's own `appLogs`/`auditLogs` mongo collections plus a dedicated capped `mcpLogs` collection of MCP tool calls. + +The section is disabled by default. A project that does not enable it needs no new packages, env variables or +infrastructure after a bundle upgrade. + +## Enabling + +1. Install the suggested packages: + ```console + $ composer require symfony/mcp-bundle symfony/rate-limiter + ``` +2. Register `Symfony\AI\McpBundle\McpBundle` in `config/bundles.php` and configure it + (`config/packages/mcp.php` — server name, version, instructions, `discovery.scan_dirs` for the project's own tools, + `client_transports: { http: true }`, `http.path`, `session.store: cache`). `client_transports.http` is mandatory — + without it McpBundle registers neither the PSR HTTP factories nor the `mcp` routing loader. The bundle prepends its + own tool directory to `discovery.scan_dirs` and provides the `cache.mcp.sessions` service, so only project-specific + values belong here. Two discovery caveats: once any `scan_dirs` value exists (including the prepended one), the + McpBundle default `['src']` no longer applies — always list the project tool directories explicitly; and the + prepended vendor path assumes the default composer `vendor` directory relative to the project root. +3. Enable the section (requires the `logs` section to be enabled; `allowed_hosts` must be non-empty — an empty list + would reject every request with 403): + ```yaml + anzu_systems_common: + mcp: + enabled: true + allowed_hosts: '%env(csv:ANZU_MCP_ALLOWED_HOSTS)%' + tool_error_exceptions: + App\Exception\SomeBackendException: 'Backend is temporarily unavailable, retry the call.' + rate_limiter: + limit: 120 + interval: '1 minute' + cache_pool: 'some_redis.cache' + session: + cache_pool: 'some_redis.cache' + logs: + mongo: + collection: 'mcpLogs' + size_mb: 200 + add_to_health_check: false + ``` + The `logs.mongo` connection options (`uri`, `username`, `password`, `database`, `ssl`) default to the + `logs.journal.mongo` connection, so they only need to be set when the mcp log collection lives elsewhere. +4. Import the MCP route (`config/routes/mcp.php`): + ```php + $routes->import('.', 'mcp'); + ``` +5. Secure the MCP endpoint with a firewall. The endpoint has no authentication on its own — pair it with the + personal access token authentication from [anzusystems/auth-bundle](https://github.com/anzusystems/auth-bundle) + or any other authenticator. +6. Create the capped mongo collection during deploy: + ```console + $ bin/console anzu:mcp:create-log-collection + ``` +7. Optionally register a monolog handler for the `mcp` channel (the bundle prepends the channel itself). + +## Provided services + +* `McpController` (alias `mcp.server.controller`) — streamable HTTP transport endpoint with DNS-rebinding protection + (`allowed_hosts`) and a per-user sliding-window rate limit. +* `McpToolExecutor` — wraps tool callbacks: converts `McpToolInputException`, `AccessDeniedException` and configured + `tool_error_exceptions` into tool error results, logs every call to the monolog `mcp` channel and to the `mcpLogs` + capped collection. +* `StrictToolArgumentsRequestHandler` — rejects tool calls with unknown arguments. +* `SearchAppLogsTool`, `SearchAuditLogsTool`, `GetLogsByContextTool` — diagnostic tools over the shared log + collections, correlated by `contextId`. diff --git a/tests/DependencyInjection/CompilerPass/McpCompilerPassTest.php b/tests/DependencyInjection/CompilerPass/McpCompilerPassTest.php new file mode 100644 index 0000000..ae03f9b --- /dev/null +++ b/tests/DependencyInjection/CompilerPass/McpCompilerPassTest.php @@ -0,0 +1,52 @@ +register('mcp.server.controller', stdClass::class); + + new McpCompilerPass() + ->process($container); + + self::assertFalse($container->hasAlias('mcp.server.controller')); + self::assertSame(stdClass::class, $container->getDefinition('mcp.server.controller')->getClass()); + } + + public function testOverridesMcpBundleControllerAndSessionCache(): void + { + $container = new ContainerBuilder(); + $container->setParameter(McpCompilerPass::SESSION_CACHE_POOL_PARAM, self::SESSION_POOL); + $container->register(McpController::class); + $container->register('mcp.server.controller', stdClass::class); + $container->register('cache.mcp.sessions', stdClass::class); + + new McpCompilerPass() + ->process($container); + + self::assertTrue($container->hasAlias('mcp.server.controller')); + self::assertSame(McpController::class, (string) $container->getAlias('mcp.server.controller')); + self::assertTrue($container->getAlias('mcp.server.controller')->isPublic()); + + $sessionCacheDefinition = $container->getDefinition('cache.mcp.sessions'); + self::assertSame(Psr16Cache::class, $sessionCacheDefinition->getClass()); + /** @var Reference $pool */ + $pool = $sessionCacheDefinition->getArgument('$pool'); + self::assertSame(self::SESSION_POOL, (string) $pool); + } +} diff --git a/tests/Mcp/McpRateLimiterTest.php b/tests/Mcp/McpRateLimiterTest.php new file mode 100644 index 0000000..018ec63 --- /dev/null +++ b/tests/Mcp/McpRateLimiterTest.php @@ -0,0 +1,76 @@ +createLimiterFactory(), + $this->createCurrentUserProvider(AnzuApp::getUserIdAnonymous()), + ); + + $this->expectException(AccessDeniedHttpException::class); + + $rateLimiter->checkRateLimit(); + } + + public function testThrowsWhenLimitExceeded(): void + { + $rateLimiter = new McpRateLimiter($this->createLimiterFactory(), $this->createCurrentUserProvider()); + $rateLimiter->checkRateLimit(); + + try { + $rateLimiter->checkRateLimit(); + self::fail('Expected ' . TooManyRequestsHttpException::class); + } catch (TooManyRequestsHttpException $exception) { + self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $exception->getStatusCode()); + $headers = $exception->getHeaders(); + self::assertSame((string) self::LIMIT, $headers['X-RateLimit-Limit']); + self::assertSame('0', $headers['X-RateLimit-Remaining']); + self::assertArrayHasKey('X-RateLimit-Reset', $headers); + self::assertArrayHasKey('Retry-After', $headers); + self::assertGreaterThanOrEqual(0, (int) $headers['Retry-After']); + } + } + + private function createLimiterFactory(): RateLimiterFactory + { + return new RateLimiterFactory( + [ + 'id' => 'mcp_test', + 'policy' => 'sliding_window', + 'limit' => self::LIMIT, + 'interval' => '1 minute', + ], + new InMemoryStorage(), + ); + } + + private function createCurrentUserProvider(int $userId = self::USER_ID): CurrentAnzuUserProvider + { + $user = $this->createConfiguredMock(AnzuUser::class, ['getId' => $userId]); + $currentUserProvider = $this->createMock(CurrentAnzuUserProvider::class); + $currentUserProvider->method('getCurrentUser') + ->willReturn($user); + + return $currentUserProvider; + } +} diff --git a/tests/Mcp/McpToolExecutorTest.php b/tests/Mcp/McpToolExecutorTest.php new file mode 100644 index 0000000..950fba8 --- /dev/null +++ b/tests/Mcp/McpToolExecutorTest.php @@ -0,0 +1,126 @@ +createExecutor() + ->execute(self::TOOL_NAME, ['foo' => 'bar'], static fn (): array => ['ok' => true]); + + self::assertSame(['ok' => true], $result); + self::assertCount(1, $this->insertedDocuments); + $document = $this->insertedDocuments[0]; + self::assertSame(McpLogger::LEVEL_NAME_INFO, $document['levelName']); + self::assertSame(self::TOOL_NAME, $document['tool']); + self::assertSame(['foo' => 'bar'], $document['params']); + self::assertSame(self::USER_ID, $document['userId']); + self::assertNull($document['error']); + } + + public function testInputExceptionIsReturnedAsToolError(): void + { + $result = $this->createExecutor() + ->execute( + self::TOOL_NAME, + [], + static fn (): array => throw new McpToolInputException('Invalid input.'), + ); + + self::assertSame('Invalid input.', $result[McpToolExecutor::ERROR_KEY]); + self::assertSame(McpLogger::LEVEL_NAME_ERROR, $this->insertedDocuments[0]['levelName']); + self::assertSame('Invalid input.', $this->insertedDocuments[0]['error']); + } + + public function testAccessDeniedIsReturnedAsToolError(): void + { + $result = $this->createExecutor() + ->execute( + self::TOOL_NAME, + [], + static fn (): array => throw new AccessDeniedException(), + ); + + self::assertStringContainsString('Access denied', $result[McpToolExecutor::ERROR_KEY]); + } + + public function testConfiguredToolErrorExceptionIsMappedToMessage(): void + { + $executor = $this->createExecutor([RuntimeException::class => self::BACKEND_ERROR_MESSAGE]); + + $result = $executor->execute( + self::TOOL_NAME, + [], + static fn (): array => throw new RuntimeException('backend down'), + ); + + self::assertSame(self::BACKEND_ERROR_MESSAGE, $result[McpToolExecutor::ERROR_KEY]); + self::assertSame(self::BACKEND_ERROR_MESSAGE, $this->insertedDocuments[0]['error']); + } + + public function testUnknownExceptionIsRethrownAndLoggedAsError(): void + { + $executor = $this->createExecutor(); + + try { + $executor->execute(self::TOOL_NAME, [], static fn (): array => throw new RuntimeException('boom')); + self::fail('Expected ' . RuntimeException::class); + } catch (RuntimeException $exception) { + self::assertSame('boom', $exception->getMessage()); + } + + self::assertCount(1, $this->insertedDocuments); + self::assertSame(McpLogger::LEVEL_NAME_ERROR, $this->insertedDocuments[0]['levelName']); + self::assertSame(sprintf('Unhandled %s', RuntimeException::class), $this->insertedDocuments[0]['error']); + } + + /** + * @param array $toolErrorExceptions + */ + private function createExecutor(array $toolErrorExceptions = []): McpToolExecutor + { + $this->insertedDocuments = []; + + $collection = $this->createMock(Collection::class); + $collection + ->method('insertOne') + ->willReturnCallback(function (array $document): InsertOneResult { + $this->insertedDocuments[] = $document; + + return $this->createStub(InsertOneResult::class); + }); + + $user = $this->createConfiguredMock(AnzuUser::class, ['getId' => self::USER_ID]); + $currentUserProvider = $this->createMock(CurrentAnzuUserProvider::class); + $currentUserProvider->method('getCurrentUser') + ->willReturn($user); + + return new McpToolExecutor( + $currentUserProvider, + new NullLogger(), + new McpLogger($collection), + $toolErrorExceptions, + ); + } +} diff --git a/tests/Mcp/Resolver/McpContextIdResolverTest.php b/tests/Mcp/Resolver/McpContextIdResolverTest.php new file mode 100644 index 0000000..e83a173 --- /dev/null +++ b/tests/Mcp/Resolver/McpContextIdResolverTest.php @@ -0,0 +1,46 @@ +resolver = new McpContextIdResolver(); + } + + public function testResolveNormalizesUuid(): void + { + self::assertSame(self::CONTEXT_ID, $this->resolver->resolve(strtoupper(self::CONTEXT_ID))); + self::assertSame(self::CONTEXT_ID, $this->resolver->resolve(' ' . self::CONTEXT_ID . ' ')); + } + + public function testResolveInvalidThrows(): void + { + $this->expectException(McpToolInputException::class); + + $this->resolver->resolve('not-a-uuid'); + } + + public function testResolveOptionalReturnsNullOnEmptyInput(): void + { + self::assertNull($this->resolver->resolveOptional(null)); + self::assertNull($this->resolver->resolveOptional('')); + self::assertNull($this->resolver->resolveOptional(' ')); + } + + public function testResolveOptionalResolvesValue(): void + { + self::assertSame(self::CONTEXT_ID, $this->resolver->resolveOptional(self::CONTEXT_ID)); + } +} diff --git a/tests/Mcp/Resolver/McpDateWindowResolverTest.php b/tests/Mcp/Resolver/McpDateWindowResolverTest.php new file mode 100644 index 0000000..86e3535 --- /dev/null +++ b/tests/Mcp/Resolver/McpDateWindowResolverTest.php @@ -0,0 +1,80 @@ +resolver = new McpDateWindowResolver(); + } + + public function testLogWindowDefaultsToOneDayBeforeUntil(): void + { + $window = $this->resolver->resolveLogWindow(null, '2026-07-20T12:00:00+00:00'); + + self::assertSame('2026-07-19T12:00:00+00:00', $window->from->format(DATE_ATOM)); + self::assertSame('2026-07-20T12:00:00+00:00', $window->until->format(DATE_ATOM)); + } + + public function testLogWindowFromIsClampedToMaxDays(): void + { + $window = $this->resolver->resolveLogWindow('2026-01-01T00:00:00+00:00', '2026-07-20T00:00:00+00:00'); + + self::assertSame('2026-06-19T00:00:00+00:00', $window->from->format(DATE_ATOM)); + } + + public function testLogWindowInvertedThrows(): void + { + $this->expectException(McpToolInputException::class); + + $this->resolver->resolveLogWindow('2026-07-20T00:00:00+00:00', '2026-07-19T00:00:00+00:00'); + } + + public function testLogWindowInvalidDateThrows(): void + { + $this->expectException(McpToolInputException::class); + + $this->resolver->resolveLogWindow('not-a-date', null); + } + + public function testArticleWindowKeepsExplicitRangeWithinCap(): void + { + $window = $this->resolver->resolveArticleWindow('2026-07-01T00:00:00+00:00', '2026-07-10T00:00:00+00:00'); + + self::assertSame('2026-07-01T00:00:00+00:00', $window->from->format(DATE_ATOM)); + self::assertSame('2026-07-10T00:00:00+00:00', $window->until->format(DATE_ATOM)); + } + + public function testArticleWindowUntilIsCappedToMaxDaysAfterFrom(): void + { + $window = $this->resolver->resolveArticleWindow('2026-01-01T00:00:00+00:00', '2026-06-01T00:00:00+00:00'); + + self::assertSame('2026-02-01T00:00:00+00:00', $window->until->format(DATE_ATOM)); + } + + public function testArticleWindowInvertedThrows(): void + { + $this->expectException(McpToolInputException::class); + + $this->resolver->resolveArticleWindow('2026-07-10T00:00:00+00:00', '2026-07-01T00:00:00+00:00'); + } + + public function testEnsureNotInvertedThrows(): void + { + $this->expectException(McpToolInputException::class); + + $this->resolver->ensureNotInverted( + $this->resolver->parseDateTime('from', '2026-07-10T00:00:00+00:00'), + $this->resolver->parseDateTime('until', '2026-07-01T00:00:00+00:00'), + ); + } +} From fcf53e221d062398aa602499257c2be96b7c11ac Mon Sep 17 00:00:00 2001 From: gabrielzigo Date: Wed, 22 Jul 2026 11:51:49 +0200 Subject: [PATCH 2/8] Add 12.0.0 changelog --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21fd533..b1e0afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [12.0.0](https://github.com/anzusystems/common-bundle/compare/11.3.0...12.0.0) (2026-07-22) + +### Features +* New opt-in `mcp` config section (disabled by default — upgrading without enabling it requires no new packages, env variables or infrastructure) built on `symfony/mcp-bundle`: `McpController` with streamable HTTP transport, DNS-rebinding protection (`allowed_hosts`, required non-empty) and a per-user sliding-window rate limit; `McpToolExecutor` translating `McpToolInputException`, `AccessDeniedException` and a configurable `tool_error_exceptions` FQCN-to-message map into tool error results while logging every call to the `mcp` monolog channel and a capped `mcpLogs` mongo collection; `StrictToolArgumentsRequestHandler` rejecting tool calls with unknown arguments. +* Diagnostic MCP tools `search_app_logs`, `search_audit_logs` and `get_logs_by_context` over the bundle-owned `appLogs`/`auditLogs` collections and the new `mcpLogs` collection, correlated by `contextId`, windows capped at 31 days and results at 50 rows with long fields truncated. +* `anzu:mcp:create-log-collection` command provisioning the capped `mcpLogs` collection idempotently; the mcp mongo connection defaults to the `logs.journal.mongo` connection, so no extra env variables are needed in the default setup. +* `JournalLogRepository::findLatest()`, `AuditLogRepository::findLatest()` and `findLatestByContextId()` — filterable newest-first raw log searches on a new shared `AbstractLogRepository` base. +* `McpCompilerPass` overriding the McpBundle `mcp.server.controller` and `cache.mcp.sessions` definitions after extension merge, so the bundle controller (rate limit, audit-log exclusion, allowed hosts) and the configured session cache pool always win regardless of bundle order. +* See `src/Resources/doc/mcp.md` for the enable checklist (requires `symfony/mcp-bundle` + `symfony/rate-limiter`, the `logs` section enabled, host-owned `config/packages/mcp.php` with `client_transports.http: true` and a route import; the MCP endpoint ships without authentication — pair it with the personal access tokens from `anzusystems/auth-bundle`). + +### Changes +* `JournalLogRepository` and `AuditLogRepository` now extend the new `AbstractLogRepository` (public API unchanged). +* New `conflict` with `symfony/mcp-bundle >=0.11` — the MCP integration compiles against the 0.10 SDK internals. + ## [11.3.0](https://github.com/anzusystems/common-bundle/compare/11.2.0...11.3.0) (2026-07-13) ### Features From bc0c69aec90b72549c5377cfdb9220d942eab260 Mon Sep 17 00:00:00 2001 From: gabrielzigo Date: Wed, 22 Jul 2026 11:52:58 +0200 Subject: [PATCH 3/8] Validate McpBundle registration in prepend where the full container is available The merge pass calls load() with a temporary container that only knows the extension being loaded, so hasExtension('mcp') is always false there. --- src/DependencyInjection/AnzuSystemsCommonExtension.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/DependencyInjection/AnzuSystemsCommonExtension.php b/src/DependencyInjection/AnzuSystemsCommonExtension.php index 8f485be..80772d1 100644 --- a/src/DependencyInjection/AnzuSystemsCommonExtension.php +++ b/src/DependencyInjection/AnzuSystemsCommonExtension.php @@ -559,6 +559,9 @@ private function prependMcp(ContainerBuilder $container): void if (false === class_exists(McpBundle::class)) { return; } + if (false === $container->hasExtension('mcp')) { + throw new LogicException('The "mcp" config section requires McpBundle to be registered in bundles.php.'); + } $container->prependExtensionConfig('monolog', [ 'channels' => ['mcp'], @@ -579,9 +582,6 @@ private function loadMcp(LoaderInterface $loader, ContainerBuilder $container): if (false === class_exists(McpBundle::class)) { throw new LogicException('The "mcp" config section requires the "symfony/mcp-bundle" package.'); } - if (false === $container->hasExtension('mcp')) { - throw new LogicException('The "mcp" config section requires McpBundle to be registered in bundles.php.'); - } if (false === class_exists(RateLimiterFactory::class)) { throw new LogicException('The "mcp" config section requires the "symfony/rate-limiter" package.'); } From f2ce5707c6f68f595a692ef85432cfc4264e6c77 Mon Sep 17 00:00:00 2001 From: gabrielzigo Date: Wed, 22 Jul 2026 12:04:27 +0200 Subject: [PATCH 4/8] Fix method chaining style in McpLogFinder --- src/Mcp/Log/McpLogFinder.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Mcp/Log/McpLogFinder.php b/src/Mcp/Log/McpLogFinder.php index 635cd9a..3f44c59 100644 --- a/src/Mcp/Log/McpLogFinder.php +++ b/src/Mcp/Log/McpLogFinder.php @@ -191,7 +191,8 @@ private function truncate(string $value): string private function formatDateTime(mixed $datetime): string { if ($datetime instanceof UTCDateTime) { - return $datetime->toDateTime()->format(DateTimeInterface::ATOM); + return $datetime->toDateTime() + ->format(DateTimeInterface::ATOM); } return self::EMPTY_STRING; From e4c95941e7a4f5db9f30fe4afa801fcaec3f4fa1 Mon Sep 17 00:00:00 2001 From: gabrielzigo Date: Wed, 22 Jul 2026 12:09:45 +0200 Subject: [PATCH 5/8] Fix required-setter parameter style reported by the current ECS ruleset --- src/Domain/AbstractManager.php | 5 ++-- .../Job/Processor/AbstractJobProcessor.php | 25 +++++++++++-------- src/Traits/EntityManagerAwareTrait.php | 5 ++-- src/Traits/JournalLoggerAwareTrait.php | 8 +++--- src/Traits/LoggerAwareRequest.php | 5 ++-- src/Traits/ResourceLockerAwareTrait.php | 5 ++-- src/Traits/SecurityAwareTrait.php | 5 ++-- src/Traits/SerializerAwareTrait.php | 5 ++-- src/Traits/ValidatorAwareTrait.php | 5 ++-- 9 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/Domain/AbstractManager.php b/src/Domain/AbstractManager.php index 8af888d..9106f6d 100644 --- a/src/Domain/AbstractManager.php +++ b/src/Domain/AbstractManager.php @@ -26,8 +26,9 @@ abstract class AbstractManager private CurrentAnzuUserProvider $currentAnzuUserProvider; #[Required] - public function setCurrentAnzuUserProvider(CurrentAnzuUserProvider $currentAnzuUserProvider): void - { + public function setCurrentAnzuUserProvider( + CurrentAnzuUserProvider $currentAnzuUserProvider + ): void { $this->currentAnzuUserProvider = $currentAnzuUserProvider; } diff --git a/src/Domain/Job/Processor/AbstractJobProcessor.php b/src/Domain/Job/Processor/AbstractJobProcessor.php index 98d8a9b..eceb75f 100644 --- a/src/Domain/Job/Processor/AbstractJobProcessor.php +++ b/src/Domain/Job/Processor/AbstractJobProcessor.php @@ -28,32 +28,37 @@ abstract class AbstractJobProcessor implements JobProcessorInterface protected EventDispatcherInterface $dispatcher; #[Required] - public function setManagerRegistry(ManagerRegistry $doctrine): void - { + public function setManagerRegistry( + ManagerRegistry $doctrine + ): void { $this->doctrine = $doctrine; } #[Required] - public function setEntityManager(EntityManagerInterface $entityManager): void - { + public function setEntityManager( + EntityManagerInterface $entityManager + ): void { $this->entityManager = $entityManager; } #[Required] - public function setCurrentAnzuUserProvider(CurrentAnzuUserProvider $currentAnzuUserProvider): void - { + public function setCurrentAnzuUserProvider( + CurrentAnzuUserProvider $currentAnzuUserProvider + ): void { $this->currentAnzuUserProvider = $currentAnzuUserProvider; } #[Required] - public function setJobManager(JobManager $jobManager): void - { + public function setJobManager( + JobManager $jobManager + ): void { $this->jobManager = $jobManager; } #[Required] - public function setDispatcher(EventDispatcherInterface $dispatcher): void - { + public function setDispatcher( + EventDispatcherInterface $dispatcher + ): void { $this->dispatcher = $dispatcher; } diff --git a/src/Traits/EntityManagerAwareTrait.php b/src/Traits/EntityManagerAwareTrait.php index 15296f2..98f5854 100644 --- a/src/Traits/EntityManagerAwareTrait.php +++ b/src/Traits/EntityManagerAwareTrait.php @@ -12,8 +12,9 @@ trait EntityManagerAwareTrait protected EntityManagerInterface $entityManager; #[Required] - public function setEntityManager(EntityManagerInterface $entityManager): void - { + public function setEntityManager( + EntityManagerInterface $entityManager + ): void { $this->entityManager = $entityManager; } } diff --git a/src/Traits/JournalLoggerAwareTrait.php b/src/Traits/JournalLoggerAwareTrait.php index 3f5dbd4..0e43c1c 100644 --- a/src/Traits/JournalLoggerAwareTrait.php +++ b/src/Traits/JournalLoggerAwareTrait.php @@ -11,12 +11,10 @@ trait JournalLoggerAwareTrait { protected ?LoggerInterface $journalLogger = null; - /** - * Sets a journal logger. - */ #[Required] - public function setJournalLogger(LoggerInterface $journalLogger): void - { + public function setJournalLogger( + LoggerInterface $journalLogger + ): void { $this->journalLogger = $journalLogger; } } diff --git a/src/Traits/LoggerAwareRequest.php b/src/Traits/LoggerAwareRequest.php index 1ee506c..96f7aec 100644 --- a/src/Traits/LoggerAwareRequest.php +++ b/src/Traits/LoggerAwareRequest.php @@ -23,8 +23,9 @@ trait LoggerAwareRequest private LogContextFactory $contextFactory; #[Required] - public function setContextFactory(LogContextFactory $contextFactory): void - { + public function setContextFactory( + LogContextFactory $contextFactory + ): void { $this->contextFactory = $contextFactory; } diff --git a/src/Traits/ResourceLockerAwareTrait.php b/src/Traits/ResourceLockerAwareTrait.php index cbb6585..ea3223d 100644 --- a/src/Traits/ResourceLockerAwareTrait.php +++ b/src/Traits/ResourceLockerAwareTrait.php @@ -12,8 +12,9 @@ trait ResourceLockerAwareTrait protected ResourceLocker $resourceLocker; #[Required] - public function setResourceLocker(ResourceLocker $resourceLocker): void - { + public function setResourceLocker( + ResourceLocker $resourceLocker + ): void { $this->resourceLocker = $resourceLocker; } } diff --git a/src/Traits/SecurityAwareTrait.php b/src/Traits/SecurityAwareTrait.php index eb7240b..20b8f46 100644 --- a/src/Traits/SecurityAwareTrait.php +++ b/src/Traits/SecurityAwareTrait.php @@ -12,8 +12,9 @@ trait SecurityAwareTrait protected Security $security; #[Required] - public function setSecurity(Security $security): void - { + public function setSecurity( + Security $security + ): void { $this->security = $security; } } diff --git a/src/Traits/SerializerAwareTrait.php b/src/Traits/SerializerAwareTrait.php index 6ba9f77..500331d 100644 --- a/src/Traits/SerializerAwareTrait.php +++ b/src/Traits/SerializerAwareTrait.php @@ -12,8 +12,9 @@ trait SerializerAwareTrait protected Serializer $serializer; #[Required] - public function setSerializer(?Serializer $serializer = null): void - { + public function setSerializer( + ?Serializer $serializer = null + ): void { if ($serializer instanceof Serializer) { $this->serializer = $serializer; } diff --git a/src/Traits/ValidatorAwareTrait.php b/src/Traits/ValidatorAwareTrait.php index e5aee58..09c8ec2 100644 --- a/src/Traits/ValidatorAwareTrait.php +++ b/src/Traits/ValidatorAwareTrait.php @@ -12,8 +12,9 @@ trait ValidatorAwareTrait protected Validator $validator; #[Required] - public function setValidator(Validator $validator): void - { + public function setValidator( + Validator $validator + ): void { $this->validator = $validator; } } From ce5497c88b04f5e91c7c5248511a9b281c0b2cc6 Mon Sep 17 00:00:00 2001 From: gabrielzigo Date: Wed, 22 Jul 2026 12:15:26 +0200 Subject: [PATCH 6/8] Fix psalm errors in McpCompilerPass Guard the session cache pool parameter type and add psr/simple-cache to require-dev so psalm can resolve the Psr16Cache dependency. --- composer.json | 1 + src/DependencyInjection/CompilerPass/McpCompilerPass.php | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2f8145f..142bf24 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ "doctrine/doctrine-bundle": "^3.0", "jetbrains/phpstorm-attributes": "^1.0", "phpunit/phpunit": "^9.5", + "psr/simple-cache": "^3.0", "slevomat/coding-standard": "8.20", "symfony/mcp-bundle": "^0.10", "symfony/rate-limiter": "^7.0|^8.0", diff --git a/src/DependencyInjection/CompilerPass/McpCompilerPass.php b/src/DependencyInjection/CompilerPass/McpCompilerPass.php index fdd2c20..f3e9d95 100644 --- a/src/DependencyInjection/CompilerPass/McpCompilerPass.php +++ b/src/DependencyInjection/CompilerPass/McpCompilerPass.php @@ -5,6 +5,7 @@ namespace AnzuSystems\CommonBundle\DependencyInjection\CompilerPass; use AnzuSystems\CommonBundle\Mcp\Controller\McpController; +use LogicException; use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -28,8 +29,13 @@ public function process(ContainerBuilder $container): void ->setAlias(self::MCP_SERVER_CONTROLLER_ID, McpController::class) ->setPublic(true); + $sessionCachePool = $container->getParameter(self::SESSION_CACHE_POOL_PARAM); + if (false === is_string($sessionCachePool)) { + throw new LogicException(sprintf('The "%s" parameter must be a cache pool service id.', self::SESSION_CACHE_POOL_PARAM)); + } + $sessionCacheDefinition = new Definition(Psr16Cache::class); - $sessionCacheDefinition->setArgument('$pool', new Reference((string) $container->getParameter(self::SESSION_CACHE_POOL_PARAM))); + $sessionCacheDefinition->setArgument('$pool', new Reference($sessionCachePool)); $container->setDefinition(self::MCP_SESSION_CACHE_ID, $sessionCacheDefinition); } } From a6aa9eeaaa0fb899c7a0a93ac5f75f5450c2677c Mon Sep 17 00:00:00 2001 From: gabrielzigo Date: Wed, 22 Jul 2026 12:38:58 +0200 Subject: [PATCH 7/8] Send Retry-After as delta seconds and guard empty allowed hosts at runtime TooManyRequestsHttpException overwrites the Retry-After header with its first argument, so pass the delta there instead of the epoch timestamp. The compile time allowed_hosts guard cannot see resolved env values, so the controller rejects an effectively empty list explicitly instead of silently returning 403 for every request. --- src/Mcp/Controller/McpController.php | 14 +++++++++++++- src/Mcp/McpRateLimiter.php | 4 ++-- src/Resources/doc/mcp.md | 2 ++ tests/Mcp/McpRateLimiterTest.php | 2 ++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Mcp/Controller/McpController.php b/src/Mcp/Controller/McpController.php index 0b93b3a..487f13e 100644 --- a/src/Mcp/Controller/McpController.php +++ b/src/Mcp/Controller/McpController.php @@ -4,8 +4,10 @@ namespace AnzuSystems\CommonBundle\Mcp\Controller; +use AnzuSystems\CommonBundle\Helper\StringHelper; use AnzuSystems\CommonBundle\Log\Helper\AuditLogResourceHelper; use AnzuSystems\CommonBundle\Mcp\McpRateLimiter; +use LogicException; use Mcp\Server; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; @@ -22,6 +24,11 @@ { private const string STREAMED_CONTENT_TYPE = 'text/event-stream'; + /** + * @var list + */ + private array $allowedHosts; + /** * @param list $allowedHosts */ @@ -33,8 +40,13 @@ public function __construct( private StreamFactoryInterface $streamFactory, private McpRateLimiter $rateLimiter, private LoggerInterface $logger, - private array $allowedHosts, + array $allowedHosts, ) { + $hosts = array_values(array_filter(array_map(trim(...), $allowedHosts), StringHelper::isNotEmpty(...))); + if ([] === $hosts) { + throw new LogicException('MCP allowed_hosts must not be empty, every request would be rejected with 403.'); + } + $this->allowedHosts = $hosts; } public function handle(Request $request): Response diff --git a/src/Mcp/McpRateLimiter.php b/src/Mcp/McpRateLimiter.php index 5e16f22..7749851 100644 --- a/src/Mcp/McpRateLimiter.php +++ b/src/Mcp/McpRateLimiter.php @@ -37,15 +37,15 @@ public function checkRateLimit(): void } $retryAfter = $limit->getRetryAfter(); + $retryAfterSeconds = max(0, $retryAfter->getTimestamp() - time()); throw new TooManyRequestsHttpException( - $retryAfter->getTimestamp(), + $retryAfterSeconds, 'Too many requests', headers: [ 'X-RateLimit-Limit' => (string) $limit->getLimit(), 'X-RateLimit-Remaining' => (string) $limit->getRemainingTokens(), 'X-RateLimit-Reset' => (string) $retryAfter->getTimestamp(), - 'Retry-After' => (string) max(0, $retryAfter->getTimestamp() - time()), ], ); } diff --git a/src/Resources/doc/mcp.md b/src/Resources/doc/mcp.md index 94d0c43..d1f9a55 100644 --- a/src/Resources/doc/mcp.md +++ b/src/Resources/doc/mcp.md @@ -44,6 +44,8 @@ infrastructure after a bundle upgrade. ``` The `logs.mongo` connection options (`uri`, `username`, `password`, `database`, `ssl`) default to the `logs.journal.mongo` connection, so they only need to be set when the mcp log collection lives elsewhere. + The `session.cache_pool` option takes effect only while the McpBundle `http.session` config keeps its default + `cache_pool: cache.mcp.sessions` — setting a custom pool there makes this option a silent no-op. 4. Import the MCP route (`config/routes/mcp.php`): ```php $routes->import('.', 'mcp'); diff --git a/tests/Mcp/McpRateLimiterTest.php b/tests/Mcp/McpRateLimiterTest.php index 018ec63..bea3cfb 100644 --- a/tests/Mcp/McpRateLimiterTest.php +++ b/tests/Mcp/McpRateLimiterTest.php @@ -18,6 +18,7 @@ final class McpRateLimiterTest extends TestCase { private const int LIMIT = 1; + private const int INTERVAL_SECONDS = 60; private const int USER_ID = 42; public function testAnonymousUserIsRejected(): void @@ -48,6 +49,7 @@ public function testThrowsWhenLimitExceeded(): void self::assertArrayHasKey('X-RateLimit-Reset', $headers); self::assertArrayHasKey('Retry-After', $headers); self::assertGreaterThanOrEqual(0, (int) $headers['Retry-After']); + self::assertLessThanOrEqual(self::INTERVAL_SECONDS, (int) $headers['Retry-After']); } } From de38c757c7395021ee6c48009b9174b26a3ff388 Mon Sep 17 00:00:00 2001 From: gabrielzigo Date: Wed, 22 Jul 2026 13:26:18 +0200 Subject: [PATCH 8/8] Move CreateMcpLogCollectionCommand to the central Command directory --- src/{Mcp => }/Command/CreateMcpLogCollectionCommand.php | 2 +- src/DependencyInjection/AnzuSystemsCommonExtension.php | 2 +- src/Resources/config/mcp.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/{Mcp => }/Command/CreateMcpLogCollectionCommand.php (98%) diff --git a/src/Mcp/Command/CreateMcpLogCollectionCommand.php b/src/Command/CreateMcpLogCollectionCommand.php similarity index 98% rename from src/Mcp/Command/CreateMcpLogCollectionCommand.php rename to src/Command/CreateMcpLogCollectionCommand.php index c43cf37..e2aed37 100644 --- a/src/Mcp/Command/CreateMcpLogCollectionCommand.php +++ b/src/Command/CreateMcpLogCollectionCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace AnzuSystems\CommonBundle\Mcp\Command; +namespace AnzuSystems\CommonBundle\Command; use MongoDB\Database; use MongoDB\Driver\Exception\CommandException; diff --git a/src/DependencyInjection/AnzuSystemsCommonExtension.php b/src/DependencyInjection/AnzuSystemsCommonExtension.php index 80772d1..c258d13 100644 --- a/src/DependencyInjection/AnzuSystemsCommonExtension.php +++ b/src/DependencyInjection/AnzuSystemsCommonExtension.php @@ -28,6 +28,7 @@ use AnzuSystems\CommonBundle\AnzuTap\Transformer\Node\XSkipTransformer; use AnzuSystems\CommonBundle\AnzuTap\TransformerProvider\AnzuTapMarkNodeTransformerProvider; use AnzuSystems\CommonBundle\AnzuTap\TransformerProvider\AnzuTapNodeTransformerProvider; +use AnzuSystems\CommonBundle\Command\CreateMcpLogCollectionCommand; use AnzuSystems\CommonBundle\Command\SyncBaseUsersCommand; use AnzuSystems\CommonBundle\Controller\DebugController; use AnzuSystems\CommonBundle\Controller\HealthCheckController; @@ -69,7 +70,6 @@ use AnzuSystems\CommonBundle\Log\LogFacade; use AnzuSystems\CommonBundle\Log\Repository\AuditLogRepository; use AnzuSystems\CommonBundle\Log\Repository\JournalLogRepository; -use AnzuSystems\CommonBundle\Mcp\Command\CreateMcpLogCollectionCommand; use AnzuSystems\CommonBundle\Mcp\Controller\McpController; use AnzuSystems\CommonBundle\Mcp\McpToolExecutor; use AnzuSystems\CommonBundle\Messenger\Message\AuditLogMessage; diff --git a/src/Resources/config/mcp.php b/src/Resources/config/mcp.php index ea991b4..9f1a171 100644 --- a/src/Resources/config/mcp.php +++ b/src/Resources/config/mcp.php @@ -4,10 +4,10 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use AnzuSystems\CommonBundle\Command\CreateMcpLogCollectionCommand; use AnzuSystems\CommonBundle\Domain\User\CurrentAnzuUserProvider; use AnzuSystems\CommonBundle\Log\Repository\AuditLogRepository; use AnzuSystems\CommonBundle\Log\Repository\JournalLogRepository; -use AnzuSystems\CommonBundle\Mcp\Command\CreateMcpLogCollectionCommand; use AnzuSystems\CommonBundle\Mcp\Controller\McpController; use AnzuSystems\CommonBundle\Mcp\Handler\StrictToolArgumentsRequestHandler; use AnzuSystems\CommonBundle\Mcp\Log\McpLogFinder;