diff --git a/config/packages/mcp.yaml b/config/packages/mcp.yaml index 0b6315c0e..408bfde7a 100644 --- a/config/packages/mcp.yaml +++ b/config/packages/mcp.yaml @@ -13,3 +13,9 @@ mcp: http: true http: path: /mcp + # Hostnames the Streamable-HTTP transport's DNS-rebinding guard accepts in + # the Host/Origin header (no port; IPv6 bracketed). The SDK default is + # localhost-only, which 403s a real domain — so every deployment must list + # its public domain in MCP_ALLOWED_HOSTS. Never `false` (that would disable + # the guard entirely). + allowed_hosts: '%env(csv:MCP_ALLOWED_HOSTS)%' diff --git a/config/services.yaml b/config/services.yaml index b83f14873..e0d0fe5d6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -24,10 +24,6 @@ parameters: ldap_usernamefield: '%env(LDAP_USERNAMEFIELD)%' ldap_usessl: '%env(bool:LDAP_USESSL)%' ldap_create_user: '%env(bool:LDAP_CREATE_USER)%' - # MCP server (ADR-021 Phase 5): hostnames the /mcp Streamable-HTTP transport's - # DNS-rebinding guard accepts in the Host/Origin header. Must include the public - # domain (the SDK default is localhost-only, which 403s a real host). - app.mcp_allowed_hosts: '%env(csv:MCP_ALLOWED_HOSTS)%' imports: - { resource: services/object_mapper.yaml } @@ -56,31 +52,12 @@ services: - '../src/Service/Integration/Jira/JiraTicketService.php' - '../src/Service/Integration/Jira/JiraAuthenticationService.php' - '../src/Service/Integration/Jira/JiraIntegrationService.php' - # Wired explicitly below (overrides the bundle's mcp.server.controller). - - '../src/Mcp/McpEndpointController.php' # Controllers are tagged automatically App\Controller\: resource: '../src/Controller/' tags: ['controller.service_arguments'] - # ADR-021 Phase 5: override the bundle's mcp.server.controller (final, hardcodes - # localhost-only DNS-rebinding hosts) with ours, host-allowlisted. Same args as - # Symfony\AI\McpBundle McpBundle::loadExtension registers, plus the allowed hosts. - mcp.server.controller: - class: App\Mcp\McpEndpointController - public: true - arguments: - - '@mcp.server' - - '@mcp.psr_http_factory' - - '@mcp.http_foundation_factory' - - '@mcp.psr17_factory' - - '@mcp.psr17_factory' - - '%app.mcp_allowed_hosts%' - - '@logger' - tags: - - 'controller.service_arguments' - # Add this if you use repositories App\Repository\: resource: '../src/Repository/' diff --git a/src/Mcp/McpEndpointController.php b/src/Mcp/McpEndpointController.php deleted file mode 100644 index 92094d3d9..000000000 --- a/src/Mcp/McpEndpointController.php +++ /dev/null @@ -1,76 +0,0 @@ - $allowedHosts hostnames (no port) permitted by the - * DNS-rebinding check; IPv6 must be bracketed - */ - public function __construct( - private Server $server, - private HttpMessageFactoryInterface $httpMessageFactory, - private HttpFoundationFactoryInterface $httpFoundationFactory, - private ResponseFactoryInterface $responseFactory, - private StreamFactoryInterface $streamFactory, - private array $allowedHosts, - private ?LoggerInterface $logger = null, - ) { - } - - public function handle(Request $request): Response - { - $transport = new StreamableHttpTransport( - $this->httpMessageFactory->createRequest($request), - $this->responseFactory, - $this->streamFactory, - logger: $this->logger, - middleware: [ - new CorsMiddleware(), - new DnsRebindingProtectionMiddleware(allowedHosts: $this->allowedHosts), - new ProtocolVersionMiddleware(), - ], - ); - - $psrResponse = $this->server->run($transport); - // Match the media type tolerant of parameters (e.g. "text/event-stream; charset=utf-8"). - $streamed = str_starts_with(strtolower($psrResponse->getHeaderLine('Content-Type')), 'text/event-stream'); - - return $this->httpFoundationFactory->createResponse($psrResponse, $streamed); - } -} diff --git a/src/Security/ApiToken/RequireScopeSubscriber.php b/src/Security/ApiToken/RequireScopeSubscriber.php index 4d6875114..d346e54e0 100644 --- a/src/Security/ApiToken/RequireScopeSubscriber.php +++ b/src/Security/ApiToken/RequireScopeSubscriber.php @@ -11,6 +11,7 @@ use App\ValueObject\ApiScope; use ReflectionMethod; +use Symfony\AI\McpBundle\Controller\McpController; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; @@ -55,8 +56,13 @@ public function onKernelController(ControllerEvent $event): void $controller = $event->getController(); $controllerObject = is_array($controller) ? $controller[0] : (is_object($controller) ? $controller : null); - if ($controllerObject instanceof SelfEnforcesScope) { - return; // controller enforces scopes per call (e.g. the MCP endpoint) + // The MCP endpoint multiplexes many tools, each requiring a different scope + // checked in its handler (App\Mcp\ScopeGuard), so a single controller-level + // #[RequireScope] cannot express its requirement. It is the one controller + // that takes responsibility for enforcing scopes on every path, so the + // fail-closed default below is bypassed for it — and only for it. + if ($controllerObject instanceof McpController) { + return; } $required = $this->requiredScope($controller); diff --git a/src/Security/ApiToken/SelfEnforcesScope.php b/src/Security/ApiToken/SelfEnforcesScope.php deleted file mode 100644 index e57c2e018..000000000 --- a/src/Security/ApiToken/SelfEnforcesScope.php +++ /dev/null @@ -1,26 +0,0 @@ -