feat: give every MCP client its own token, rate limit and log name
This commit is contained in:
1 parent
3af41c06ab
commit
bfdedefae3
8 files changed
+134
-33
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user