feat: give every MCP client its own token, rate limit and log name

This commit is contained in:
fromme committed 2026-10-08 12:37:49 +02:00
1 parent 3af41c06ab
commit bfdedefae3
8 files changed
+134 -33

No files matched your search

+9 -6
View File
@@ -12,12 +12,13 @@ use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\RateLimiter\RateLimiterFactory;
/**
* What every chatbot tool call goes through: the per-token rate limit, the call log, and
* What every chatbot tool call goes through: the per-client rate limit, the call log, and
* the answer as compact JSON.
*
* The SDK would pretty-print an array result and repeat it as structured content; both
* cost the model tokens and neither helps n8n. The log records tool, arguments, result
* count and duration, never anything about the person chatting, which n8n does not send.
* count, duration and the calling client (n8n, claude-desktop, …), never anything about
* the person chatting, which n8n does not send.
*/
class ToolCallRunner
{
@@ -37,10 +38,11 @@ class ToolCallRunner
public function run(string $tool, array $arguments, callable $call): CallToolResult
{
$arguments = array_filter($arguments, static fn (mixed $value): bool => null !== $value);
$limit = $this->chatbotMcpLimiter->create($this->security->getUser()?->getUserIdentifier() ?? 'anonymous')->consume();
$client = $this->security->getUser()?->getUserIdentifier() ?? 'anonymous';
$limit = $this->chatbotMcpLimiter->create($client)->consume();
if (!$limit->isAccepted()) {
$this->chatbotLogger->warning('MCP tool call rate-limited', ['tool' => $tool, 'arguments' => $arguments]);
$this->chatbotLogger->warning('MCP tool call rate-limited', ['client' => $client, 'tool' => $tool, 'arguments' => $arguments]);
throw new ToolCallException(sprintf('Too many tool calls. Retry after %s.', $limit->getRetryAfter()->format('H:i:s')));
}
@@ -50,16 +52,17 @@ class ToolCallRunner
try {
$result = $call();
} catch (ToolCallException $e) {
$this->chatbotLogger->info('MCP tool call rejected', ['tool' => $tool, 'arguments' => $arguments, 'error' => $e->getMessage()]);
$this->chatbotLogger->info('MCP tool call rejected', ['client' => $client, 'tool' => $tool, 'arguments' => $arguments, 'error' => $e->getMessage()]);
throw $e;
} catch (\Throwable $e) {
$this->chatbotLogger->error('MCP tool call failed', ['tool' => $tool, 'arguments' => $arguments, 'exception' => $e]);
$this->chatbotLogger->error('MCP tool call failed', ['client' => $client, 'tool' => $tool, 'arguments' => $arguments, 'exception' => $e]);
throw $e;
}
$this->chatbotLogger->info('MCP tool call', [
'client' => $client,
'tool' => $tool,
'arguments' => $arguments,
'resultCount' => is_array($result['results'] ?? null) ? count($result['results']) : 1,
+60 -7
View File
@@ -5,33 +5,86 @@ declare(strict_types=1);
namespace App\Security;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\InMemoryUser;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
/**
* Checks the static bearer token n8n sends to the chatbot MCP endpoint.
* Checks the static bearer tokens of the systems calling the chatbot MCP endpoint.
*
* The data behind it is public, but the endpoint must not become a free LLM-tooling API
* for whoever finds it. A static token rather than the OAuth2 client-credentials grant of
* for whoever finds it. Static tokens rather than the OAuth2 client-credentials grant of
* /api: n8n's MCP Client Tool node sends a fixed header and cannot refresh a 10-minute token.
* An unconfigured token refuses every request.
*
* Every client has its own token, configured as `name:token` pairs separated by commas
* (APP_MCP_TOKENS="n8n:3f9c…,claude-desktop:a71e…"), so one can be revoked without the
* others. The name becomes the authenticated user: each client gets its own rate-limit
* bucket and is named in the tool-call log. An empty list refuses every request.
*/
final class McpAccessTokenHandler implements AccessTokenHandlerInterface
{
public const USER = 'n8n';
public const ROLE = 'ROLE_CHATBOT_MCP';
/** @var array<string, string> token => client name */
private readonly array $clients;
/**
* @throws \InvalidArgumentException for a malformed list, so a typo fails loudly instead of locking a client out
*/
public function __construct(
#[\SensitiveParameter]
private readonly string $token,
string $tokens,
) {
$this->clients = self::parse($tokens);
}
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
if ('' === $this->token || !hash_equals($this->token, $accessToken)) {
$client = null;
// Every token is compared, so the time taken does not tell which one came close.
foreach ($this->clients as $token => $name) {
if (hash_equals($token, $accessToken)) {
$client = $name;
}
}
if (null === $client) {
throw new BadCredentialsException('Invalid MCP token.');
}
return new UserBadge(self::USER);
return new UserBadge($client, static fn (string $name): InMemoryUser => new InMemoryUser($name, null, [self::ROLE]));
}
/**
* @return array<string, string> token => client name
*/
private static function parse(string $tokens): array
{
$clients = [];
foreach (explode(',', $tokens) as $entry) {
if ('' === trim($entry)) {
continue;
}
[$name, $token] = array_map('trim', explode(':', $entry, 2)) + [1 => ''];
if (1 !== preg_match('/^[a-z0-9_-]+$/i', $name) || '' === $token) {
throw new \InvalidArgumentException('APP_MCP_TOKENS entries must look like "name:token", with a name of letters, digits, "-" and "_".');
}
if (in_array($name, $clients, true)) {
throw new \InvalidArgumentException(sprintf('APP_MCP_TOKENS names client "%s" twice.', $name));
}
if (isset($clients[$token])) {
throw new \InvalidArgumentException(sprintf('APP_MCP_TOKENS gives clients "%s" and "%s" the same token.', $clients[$token], $name));
}
$clients[$token] = $name;
}
return $clients;
}
}