feat : MCP server infrastructure setup

Install symfony/mcp-bundle, add STDIO + HTTP transport config,
API token auth on User entity with custom authenticator and firewall,
generate-api-token console command, Nginx /_mcp location, fixture token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:33:52 +01:00
parent 760f5b6ad6
commit e16fd2053e
12 changed files with 989 additions and 26 deletions

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
#[AsCommand(
name: 'app:generate-api-token',
description: 'Generate or regenerate an API token for a user (used for MCP HTTP authentication)',
)]
class GenerateApiTokenCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('username', InputArgument::REQUIRED, 'The username to generate a token for');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$username = $input->getArgument('username');
$user = $this->userRepository->findOneBy(['username' => $username]);
if (null === $user) {
$io->error(sprintf('User "%s" not found.', $username));
return Command::FAILURE;
}
$token = bin2hex(random_bytes(32));
$user->setApiToken($token);
$this->entityManager->flush();
$io->success(sprintf('API token generated for user "%s":', $username));
$io->writeln($token);
return Command::SUCCESS;
}
}

View File

@@ -33,6 +33,7 @@ class AppFixtures extends Fixture
$admin->setUsername('admin');
$admin->setRoles(['ROLE_ADMIN']);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$admin->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production');
$manager->persist($admin);
// Clients

View File

@@ -67,6 +67,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(length: 64, unique: true, nullable: true)]
private ?string $apiToken = null;
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:list', 'user:write'])]
@@ -184,5 +187,17 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getApiToken(): ?string
{
return $this->apiToken;
}
public function setApiToken(?string $apiToken): static
{
$this->apiToken = $apiToken;
return $this;
}
public function eraseCredentials(): void {}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class ApiTokenAuthenticator extends AbstractAuthenticator
{
public function __construct(
private readonly UserRepository $userRepository,
) {}
public function supports(Request $request): ?bool
{
return $request->headers->has('Authorization')
&& str_starts_with((string) $request->headers->get('Authorization'), 'Bearer ');
}
public function authenticate(Request $request): Passport
{
$authHeader = (string) $request->headers->get('Authorization');
$token = substr($authHeader, 7);
if ('' === $token) {
throw new CustomUserMessageAuthenticationException('API token missing.');
}
return new SelfValidatingPassport(
new UserBadge($token, function (string $token): ?User {
$user = $this->userRepository->findOneBy(['apiToken' => $token]);
if (null === $user) {
throw new CustomUserMessageAuthenticationException('Invalid API token.');
}
return $user;
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(
['error' => $exception->getMessageKey()],
Response::HTTP_UNAUTHORIZED
);
}
}