Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,22 @@
"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",
"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"
Expand Down
2 changes: 2 additions & 0 deletions src/AnzuSystemsCommonBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
}
}
99 changes: 99 additions & 0 deletions src/Command/CreateMcpLogCollectionCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace AnzuSystems\CommonBundle\Command;

use MongoDB\Database;
use MongoDB\Driver\Exception\CommandException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
name: 'anzu:mcp:create-log-collection',
description: 'Create the capped MongoDB collection for MCP tool call logs.'
)]
final class CreateMcpLogCollectionCommand extends Command
{
private const int BYTES_PER_MEGABYTE = 1_024 * 1_024;
private const string CAPPED_OPTION = 'capped';
private const int NAMESPACE_EXISTS_ERROR_CODE = 48;

public function __construct(
private readonly Database $mcpLogDatabase,
private readonly string $mcpLogCollectionName,
private readonly int $mcpLogCollectionSizeMb,
) {
parent::__construct();
}

public function __invoke(SymfonyStyle $io): int
{
$options = $this->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<string, mixed> $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<string, mixed>|null
*/
private function findCollectionOptions(): ?array
{
foreach ($this->mcpLogDatabase->listCollections([
'filter' => [
'name' => $this->mcpLogCollectionName,
],
]) as $collectionInfo) {
return $collectionInfo->getOptions();
}

return null;
}
}
142 changes: 142 additions & 0 deletions src/DependencyInjection/AnzuSystemsCommonExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@
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;
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;
Expand Down Expand Up @@ -68,6 +70,8 @@
use AnzuSystems\CommonBundle\Log\LogFacade;
use AnzuSystems\CommonBundle\Log\Repository\AuditLogRepository;
use AnzuSystems\CommonBundle\Log\Repository\JournalLogRepository;
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;
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -117,6 +127,8 @@ public function prepend(ContainerBuilder $container): void
$container->getExtensionConfig($this->getAlias())
);

$this->prependMcp($container);

$container->prependExtensionConfig('doctrine', [
'orm' => [
'dql' => [
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
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'],
]);
$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 === 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(
Expand Down
Loading
Loading