Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## [Unreleased]

### Features
* Per-tool MCP permissions: `mcp.tool_permissions` maps every tool name to an existing host permission, resolved by `McpToolAccessChecker` through the host's standard permission model (unmapped tool = denied, super admin bypass). `McpToolExecutor::execute()` answers unauthorized `tools/call` with a tool error result logged into `mcpLogs` before running the tool callback (BC: the executor now requires `McpToolAccessChecker`), `FilterToolsListRequestHandler` hides unauthorized tools from `tools/list`.
* Log search queries (`JournalLogRepository::findLatest()`, `AuditLogRepository::findLatest()`, `findLatestByContextId()`, `McpLogRepository::findLatestByContextId()`) are bounded and sorted by the built-in `_id` index instead of the unindexed `datetime` sort; new `MongoHelper` with `minObjectIdFor()` / `maxObjectIdFor()` / `sortNewestFirst()` and `FIELD_ID` / `SORT_DESC` constants. See the "Log query bounds" section of `src/Resources/doc/mcp.md` for the slack assumptions.
* `McpRateLimiter` honours a per-caller override from the security token attributes `McpRateLimiter::TOKEN_ATTRIBUTE_KEY` (bucket key) and `McpRateLimiter::TOKEN_ATTRIBUTE_LIMIT` (limit replacing the configured default), so authenticators can rate-limit per personal access token; the limiter now takes the configured `limit` + `interval` and the storage directly (the `anzu_systems_common.mcp.rate_limiter_factory` service is gone).

### Changes
* BC change: `AbstractLogRepository::FIELD_ID` (protected) moved to `MongoHelper::FIELD_ID`; `McpToolExecutor` requires `McpToolAccessChecker` (its tool-permission check).

## [12.0.0](https://github.com/anzusystems/common-bundle/compare/11.3.0...12.0.0) (2026-07-22)

### Features
Expand Down
19 changes: 10 additions & 9 deletions src/DependencyInjection/AnzuSystemsCommonExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@
use AnzuSystems\CommonBundle\Log\Repository\AuditLogRepository;
use AnzuSystems\CommonBundle\Log\Repository\JournalLogRepository;
use AnzuSystems\CommonBundle\Mcp\Controller\McpController;
use AnzuSystems\CommonBundle\Mcp\McpRateLimiter;
use AnzuSystems\CommonBundle\Mcp\McpToolExecutor;
use AnzuSystems\CommonBundle\Mcp\Security\McpToolAccessChecker;
use AnzuSystems\CommonBundle\Messenger\Message\AuditLogMessage;
use AnzuSystems\CommonBundle\Messenger\Message\JournalLogMessage;
use AnzuSystems\CommonBundle\Request\ParamConverter\ApiFilterParamConverter;
Expand Down Expand Up @@ -624,15 +626,10 @@ private function loadMcp(LoaderInterface $loader, ContainerBuilder $container):
$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(McpRateLimiter::class)
->replaceArgument('$limit', $mcp['rate_limiter']['limit'])
->replaceArgument('$interval', $mcp['rate_limiter']['interval']);

$container
->getDefinition(McpController::class)
Expand All @@ -642,6 +639,10 @@ private function loadMcp(LoaderInterface $loader, ContainerBuilder $container):
->getDefinition(McpToolExecutor::class)
->replaceArgument('$toolErrorExceptions', $mcp['tool_error_exceptions']);

$container
->getDefinition(McpToolAccessChecker::class)
->replaceArgument('$toolPermissions', $mcp['tool_permissions']);

$container
->getDefinition(CreateMcpLogCollectionCommand::class)
->replaceArgument('$mcpLogCollectionName', $mongo['collection'])
Expand Down
4 changes: 4 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,10 @@ private function addMcpSection(): NodeDefinition
->end()
->scalarPrototype()->end()
->end()
->arrayNode('tool_permissions')
->useAttributeAsKey('tool')
->scalarPrototype()->end()
->end()
->arrayNode('rate_limiter')
->addDefaultsIfNotSet()
->children()
Expand Down
68 changes: 68 additions & 0 deletions src/Helper/MongoHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace AnzuSystems\CommonBundle\Helper;

use DateTimeImmutable;
use MongoDB\BSON\ObjectId;
use MongoDB\BSON\UTCDateTime;

final class MongoHelper
{
public const string FIELD_ID = '_id';
public const int SORT_DESC = -1;

private const int TIMESTAMP_MIN = 0;
private const int TIMESTAMP_MAX = 0xFFFFFFFF;
private const string TIMESTAMP_HEX_FORMAT = '%08x';
private const string OBJECT_ID_MIN_SUFFIX = '0000000000000000';
private const string OBJECT_ID_MAX_SUFFIX = 'ffffffffffffffff';
private const int DATETIME_MILLIS_FALLBACK = 0;

public static function minObjectIdFor(DateTimeImmutable $datetime): ObjectId
{
return new ObjectId(self::timestampHex($datetime) . self::OBJECT_ID_MIN_SUFFIX);
}

public static function maxObjectIdFor(DateTimeImmutable $datetime): ObjectId
{
return new ObjectId(self::timestampHex($datetime) . self::OBJECT_ID_MAX_SUFFIX);
}

/**
* @param list<array<string, mixed>> $documents
*
* @return list<array<string, mixed>>
*/
public static function sortNewestFirst(array $documents, string $datetimeField): array
{
usort(
$documents,
static fn (array $a, array $b): int => self::datetimeMillis($b, $datetimeField) <=> self::datetimeMillis($a, $datetimeField),
);

return $documents;
}

private static function timestampHex(DateTimeImmutable $datetime): string
{
return sprintf(
self::TIMESTAMP_HEX_FORMAT,
min(self::TIMESTAMP_MAX, max(self::TIMESTAMP_MIN, $datetime->getTimestamp())),
);
}

/**
* @param array<string, mixed> $document
*/
private static function datetimeMillis(array $document, string $datetimeField): int
{
$datetime = $document[$datetimeField] ?? null;
if ($datetime instanceof UTCDateTime) {
return (int) (string) $datetime;
}

return self::DATETIME_MILLIS_FALLBACK;
}
}
15 changes: 12 additions & 3 deletions src/Log/Repository/AbstractLogRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace AnzuSystems\CommonBundle\Log\Repository;

use AnzuSystems\CommonBundle\Document\Log;
use AnzuSystems\CommonBundle\Helper\MongoHelper;
use AnzuSystems\CommonBundle\Repository\Mongo\AbstractAnzuMongoRepository;
use DateTimeImmutable;
use MongoDB\BSON\UTCDateTime;
Expand All @@ -24,7 +25,8 @@ abstract class AbstractLogRepository extends AbstractAnzuMongoRepository
protected const string MONGO_EXISTS = '$exists';

private const int LIMIT_MIN = 1;
private const int SORT_DESC = -1;
private const string ID_LOWER_BOUND_SLACK = '-1 minute';
private const string ID_UPPER_BOUND_SLACK = '+1 day';
private const array RAW_ARRAY_TYPE_MAP = [
'root' => 'array',
'document' => 'array',
Expand All @@ -41,6 +43,9 @@ public function findLatestByContextId(string $contextId, DateTimeImmutable $from
self::FIELD_DATETIME => [
self::MONGO_GTE => new UTCDateTime($from),
],
MongoHelper::FIELD_ID => [
self::MONGO_GTE => MongoHelper::minObjectIdFor($from->modify(self::ID_LOWER_BOUND_SLACK)),
],
], $limit);
}

Expand All @@ -58,14 +63,14 @@ protected function findLatestRawDocuments(array $match, int $limit): array
{
$documents = $this->collection->find($match, [
'sort' => [
self::FIELD_DATETIME => self::SORT_DESC,
MongoHelper::FIELD_ID => MongoHelper::SORT_DESC,
],
'limit' => max(self::LIMIT_MIN, $limit),
'maxTimeMS' => $this->queryMaxTimeMs,
'typeMap' => self::RAW_ARRAY_TYPE_MAP,
]);

return array_values($documents->toArray());
return MongoHelper::sortNewestFirst(array_values($documents->toArray()), self::FIELD_DATETIME);
}

/**
Expand All @@ -78,6 +83,10 @@ protected function createDatetimeWindowMatch(DateTimeImmutable $from, DateTimeIm
self::MONGO_GTE => new UTCDateTime($from),
self::MONGO_LTE => new UTCDateTime($until),
],
MongoHelper::FIELD_ID => [
self::MONGO_GTE => MongoHelper::minObjectIdFor($from->modify(self::ID_LOWER_BOUND_SLACK)),
self::MONGO_LTE => MongoHelper::maxObjectIdFor($until->modify(self::ID_UPPER_BOUND_SLACK)),
],
];
}
}
54 changes: 54 additions & 0 deletions src/Mcp/Handler/FilterToolsListRequestHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace AnzuSystems\CommonBundle\Mcp\Handler;

use AnzuSystems\CommonBundle\Mcp\Security\McpToolAccessChecker;
use Mcp\Capability\RegistryInterface;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\ListToolsRequest;
use Mcp\Schema\Result\ListToolsResult;
use Mcp\Schema\Tool;
use Mcp\Server\Handler\Request\RequestHandlerInterface;
use Mcp\Server\Session\SessionInterface;

/**
* @implements RequestHandlerInterface<ListToolsResult>
*/
final readonly class FilterToolsListRequestHandler implements RequestHandlerInterface
{
public function __construct(
private RegistryInterface $registry,
private McpToolAccessChecker $toolAccessChecker,
private int $pageSize,
) {
}

public function supports(Request $request): bool
{
return $request instanceof ListToolsRequest;
}

public function handle(Request $request, SessionInterface $session): Response|Error
{
if (false === $request instanceof ListToolsRequest) {
return Error::forInternalError('Unsupported request.', $request->getId());
}

$page = $this->registry->getTools($this->pageSize, $request->cursor);
$grantedTools = [];
foreach ($page->references as $reference) {
if ($reference instanceof Tool && $this->toolAccessChecker->isToolGranted($reference->name)) {
$grantedTools[] = $reference;
}
}

return new Response(
$request->getId(),
new ListToolsResult($grantedTools, $page->nextCursor),
);
}
}
10 changes: 7 additions & 3 deletions src/Mcp/Log/McpLogRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace AnzuSystems\CommonBundle\Mcp\Log;

use AnzuSystems\CommonBundle\ApiFilter\ApiQueryMongo;
use AnzuSystems\CommonBundle\Helper\MongoHelper;
use DateTimeImmutable;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Collection;
Expand All @@ -15,7 +16,7 @@
private const string FIELD_CONTEXT_ID = 'contextId';
private const string MONGO_GTE = '$gte';
private const int LIMIT_MIN = 1;
private const int SORT_DESC = -1;
private const string ID_LOWER_BOUND_SLACK = '-1 minute';
private const array RAW_ARRAY_TYPE_MAP = [
'root' => 'array',
'document' => 'array',
Expand All @@ -38,15 +39,18 @@ public function findLatestByContextId(string $contextId, DateTimeImmutable $from
self::FIELD_DATETIME => [
self::MONGO_GTE => new UTCDateTime($from),
],
MongoHelper::FIELD_ID => [
self::MONGO_GTE => MongoHelper::minObjectIdFor($from->modify(self::ID_LOWER_BOUND_SLACK)),
],
], [
'sort' => [
self::FIELD_DATETIME => self::SORT_DESC,
MongoHelper::FIELD_ID => MongoHelper::SORT_DESC,
],
'limit' => max(self::LIMIT_MIN, $limit),
'maxTimeMS' => $this->queryMaxTimeMs,
'typeMap' => self::RAW_ARRAY_TYPE_MAP,
]);

return array_values($documents->toArray());
return MongoHelper::sortNewestFirst(array_values($documents->toArray()), self::FIELD_DATETIME);
}
}
42 changes: 39 additions & 3 deletions src/Mcp/McpRateLimiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,29 @@
namespace AnzuSystems\CommonBundle\Mcp;

use AnzuSystems\CommonBundle\Domain\User\CurrentAnzuUserProvider;
use AnzuSystems\CommonBundle\Helper\StringHelper;
use AnzuSystems\Contracts\AnzuApp;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\StorageInterface;

final readonly class McpRateLimiter
{
public const string TOKEN_ATTRIBUTE_KEY = 'mcp_rate_limit_key';
public const string TOKEN_ATTRIBUTE_LIMIT = 'mcp_rate_limit';

private const string LIMITER_ID = 'mcp';
private const string LIMITER_POLICY = 'sliding_window';

public function __construct(
private RateLimiterFactory $mcpLimiter,
private int $limit,
private string $interval,
private StorageInterface $storage,
private CurrentAnzuUserProvider $currentUserProvider,
private Security $security,
) {
}

Expand All @@ -30,8 +43,11 @@ public function checkRateLimit(): void
throw new AccessDeniedHttpException('Anonymous access to the MCP endpoint is not allowed.');
}

$limiter = $this->mcpLimiter->create((string) $userId);
$limit = $limiter->consume();
$key = $this->resolveTokenAttribute(self::TOKEN_ATTRIBUTE_KEY);
$limit = $this->createLimiter(
is_string($key) && StringHelper::isNotEmpty($key) ? $key : (string) $userId,
$this->resolveTokenAttribute(self::TOKEN_ATTRIBUTE_LIMIT),
)->consume();
if ($limit->isAccepted()) {
return;
}
Expand All @@ -49,4 +65,24 @@ public function checkRateLimit(): void
],
);
}

private function resolveTokenAttribute(string $name): mixed
{
return $this->security->getToken()?->getAttributes()[$name] ?? null;
}

private function createLimiter(string $key, mixed $limitOverride): LimiterInterface
{
$limit = is_int($limitOverride) && $limitOverride > 0 ? $limitOverride : $this->limit;

return new RateLimiterFactory(
[
'id' => self::LIMITER_ID,
'policy' => self::LIMITER_POLICY,
'limit' => $limit,
'interval' => $this->interval,
],
$this->storage,
)->create($key);
}
}
10 changes: 10 additions & 0 deletions src/Mcp/McpToolExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use AnzuSystems\CommonBundle\Domain\User\CurrentAnzuUserProvider;
use AnzuSystems\CommonBundle\Mcp\Exception\McpToolInputException;
use AnzuSystems\CommonBundle\Mcp\Log\McpLogger;
use AnzuSystems\CommonBundle\Mcp\Security\McpToolAccessChecker;
use Closure;
use Monolog\Attribute\WithMonologChannel;
use Psr\Log\LoggerInterface;
Expand All @@ -18,13 +19,16 @@
{
public const string ERROR_KEY = 'error';

private const string TOOL_ACCESS_DENIED_MESSAGE = 'Access denied — the current MCP user is not allowed to use the tool "%s".';

/**
* @param array<class-string<Throwable>, string> $toolErrorExceptions
*/
public function __construct(
private CurrentAnzuUserProvider $currentUserProvider,
private LoggerInterface $logger,
private McpLogger $mcpLogger,
private McpToolAccessChecker $toolAccessChecker,
private array $toolErrorExceptions = [],
) {
}
Expand All @@ -41,6 +45,12 @@ public function execute(string $toolName, array $params, Closure $callback): arr
$error = null;

try {
if (false === $this->toolAccessChecker->isToolGranted($toolName)) {
$error = sprintf(self::TOOL_ACCESS_DENIED_MESSAGE, $toolName);

return [self::ERROR_KEY => $error];
}

return $callback();
} catch (McpToolInputException $exception) {
$error = $exception->getMessage();
Expand Down
Loading
Loading