|
| 1 | +# MCP Server Infrastructure (Experimental) |
| 2 | + |
| 3 | +Pimcore Studio Backend provides shared infrastructure for [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) |
| 4 | +servers across bundles. This includes a dedicated security firewall, PSR-7/PSR-17 bridge services, and a dual authentication |
| 5 | +system supporting both internal agent use and external MCP clients. |
| 6 | + |
| 7 | +## Architecture Overview |
| 8 | + |
| 9 | +### The `pimcore_mcp` Firewall |
| 10 | + |
| 11 | +All MCP endpoints use the URL prefix `/pimcore-mcp/` and are protected by a dedicated Symfony firewall (`pimcore_mcp`). This |
| 12 | +firewall is **stateless** — each request authenticates independently, with no session migration or security token persistence. |
| 13 | +It is separate from the `pimcore_studio` firewall to provide security isolation — MCP authentication cannot leak to Studio |
| 14 | +Backend API routes and vice versa. |
| 15 | + |
| 16 | +The firewall supports two authenticators tried in order: |
| 17 | + |
| 18 | +| Authenticator | Trigger | Use Case | |
| 19 | +|------------------------------|--------------------------|-----------------------------------------------------------| |
| 20 | +| `SessionBridgeAuthenticator` | Session cookie | Internal: agent-server forwarding Pimcore Studio sessions | |
| 21 | +| `PatAuthenticator` | `Authorization: Bearer` | External: MCP clients (Claude Desktop, Cursor, etc.) | |
| 22 | + |
| 23 | +Both authenticators resolve to a Pimcore `User` object. All existing Pimcore permissions (workspace ACLs, user/role |
| 24 | +permissions) apply automatically. |
| 25 | + |
| 26 | +### SessionBridgeAuthenticator |
| 27 | + |
| 28 | +The `SessionBridgeAuthenticator` authenticates MCP requests against an existing Pimcore Studio session. When the Pimcore AI |
| 29 | +agent-server receives a request from the Studio UI, it forwards the browser's `PHPSESSID` cookie to the MCP endpoint. The |
| 30 | +authenticator reads `_security_pimcore_admin` from the PHP session (cross-context) via the `AuthenticationResolverInterface` |
| 31 | +to resolve the authenticated Pimcore user. It validates that the user exists and is active before creating a |
| 32 | +`SelfValidatingPassport`. |
| 33 | + |
| 34 | +This authenticator returns `null` on failure (rather than an error response), allowing the next authenticator in the chain |
| 35 | +(PAT) to try. |
| 36 | + |
| 37 | +### PSR-7/PSR-17 Bridge Services |
| 38 | + |
| 39 | +Studio Backend Bundle provides the PSR-7/PSR-17 bridge services required by MCP controllers globally. Bundles that implement |
| 40 | +MCP servers do not need to register these services themselves: |
| 41 | + |
| 42 | +- `Psr\Http\Message\ResponseFactoryInterface` |
| 43 | +- `Psr\Http\Message\StreamFactoryInterface` |
| 44 | +- `Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface` |
| 45 | +- `Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface` |
| 46 | + |
| 47 | +These are defined in `config/mcp.yaml` and available for autowiring in any bundle. |
| 48 | + |
| 49 | +## Authentication |
| 50 | + |
| 51 | +### Session Bridge (Internal) |
| 52 | + |
| 53 | +When the Pimcore AI agent-server receives a request from the Studio UI, it forwards the browser's `PHPSESSID` cookie to the |
| 54 | +MCP endpoint. The `SessionBridgeAuthenticator` calls `AuthenticationResolverInterface::authenticateSession()` to read the |
| 55 | +`_security_pimcore_admin` token from the PHP session, resolving the Pimcore user who is logged into Studio. |
| 56 | + |
| 57 | +This authenticator returns `null` on failure (rather than an error response), allowing the next authenticator (PAT) to try. |
| 58 | + |
| 59 | +### Personal Access Tokens (External) |
| 60 | + |
| 61 | +External MCP clients authenticate with bearer tokens configured in YAML: |
| 62 | + |
| 63 | +```yaml |
| 64 | +# config/config.yaml or config/packages/pimcore_studio_backend.yaml |
| 65 | +pimcore_studio_backend: |
| 66 | + mcp: |
| 67 | + authentication: |
| 68 | + tokens: |
| 69 | + admin: |
| 70 | + - '%env(MCP_TOKEN_ADMIN)%' |
| 71 | + editor_user: |
| 72 | + - '%env(MCP_TOKEN_EDITOR)%' |
| 73 | +``` |
| 74 | +
|
| 75 | +Each key is a Pimcore username, and the value is a list of accepted tokens for that user. Tokens can reference environment |
| 76 | +variables to keep secrets out of YAML files. |
| 77 | +
|
| 78 | +The `PatAuthenticator` extracts the bearer token from the `Authorization` header, looks up the username in the token map, |
| 79 | +loads the Pimcore `User`, validates it is active, and creates a `SelfValidatingPassport`. |
| 80 | + |
| 81 | +**Client configuration example (Claude Desktop / Cursor):** |
| 82 | + |
| 83 | +```json |
| 84 | +{ |
| 85 | + "mcpServers": { |
| 86 | + "pimcore": { |
| 87 | + "url": "https://your-pimcore.com/pimcore-mcp/agent/pimcore-data-objects-read", |
| 88 | + "headers": { |
| 89 | + "Authorization": "Bearer <your-token>" |
| 90 | + } |
| 91 | + } |
| 92 | + } |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +### How Permissions Work |
| 97 | + |
| 98 | +PATs grant full access as the mapped Pimcore user. There are no MCP-specific scopes — fine-grained restrictions use existing |
| 99 | +Pimcore workspace and user permissions. If a user cannot edit a data object via the admin UI, they cannot edit it via MCP |
| 100 | +tools either. |
| 101 | + |
| 102 | +## Configuration Reference |
| 103 | + |
| 104 | +```yaml |
| 105 | +pimcore_studio_backend: |
| 106 | + mcp: |
| 107 | + authentication: |
| 108 | + tokens: |
| 109 | + # Map: Pimcore username => list of bearer tokens |
| 110 | + <username>: |
| 111 | + - '<token-string-or-env-ref>' |
| 112 | +``` |
| 113 | + |
| 114 | +The firewall is automatically configured by the bundle extension. To enable it, add the following to your |
| 115 | +`config/packages/security.yaml` (see also [Installation](./00_Installation.md)): |
| 116 | + |
| 117 | +```yaml |
| 118 | +security: |
| 119 | + firewalls: |
| 120 | + pimcore_mcp: '%pimcore_studio_backend.mcp_firewall_settings%' |
| 121 | + access_control: |
| 122 | + - { path: ^/pimcore-mcp/, roles: ROLE_PIMCORE_USER } |
| 123 | +``` |
| 124 | + |
| 125 | +No manual firewall configuration beyond this is needed — the parameter contains the full firewall definition including the |
| 126 | +authenticator chain, user provider, and stateless flag. |
| 127 | + |
| 128 | +## Implementing an MCP Server in a Bundle |
| 129 | + |
| 130 | +### Step 1: Create MCP Tool Classes |
| 131 | + |
| 132 | +Use the `mcp/sdk` package attributes to define tools: |
| 133 | + |
| 134 | +```php |
| 135 | +use Mcp\Attribute\McpTool; |
| 136 | +use Mcp\Attribute\Schema; |
| 137 | +use Mcp\Types\CallToolResult; |
| 138 | +use Mcp\Types\TextContent; |
| 139 | +
|
| 140 | +final readonly class MyTool |
| 141 | +{ |
| 142 | + #[McpTool( |
| 143 | + name: 'my_tool_name', |
| 144 | + description: 'What this tool does' |
| 145 | + )] |
| 146 | + public function execute( |
| 147 | + #[Schema(description: 'Parameter description')] |
| 148 | + string $param |
| 149 | + ): CallToolResult { |
| 150 | + // Tool implementation |
| 151 | + return new CallToolResult( |
| 152 | + [new TextContent('Result')] |
| 153 | + ); |
| 154 | + } |
| 155 | +} |
| 156 | +``` |
| 157 | + |
| 158 | +### Step 2: Register Tools as Services |
| 159 | + |
| 160 | +```yaml |
| 161 | +# config/services.yaml (in your bundle) |
| 162 | +services: |
| 163 | + My\Bundle\Mcp\Tool\MyTool: ~ |
| 164 | +``` |
| 165 | + |
| 166 | +### Step 3: Create the MCP Server |
| 167 | + |
| 168 | +Build a server using the SDK, referencing your tool classes: |
| 169 | + |
| 170 | +```php |
| 171 | +use Mcp\Server; |
| 172 | +use Mcp\ServerBuilder; |
| 173 | +
|
| 174 | +$builder = new ServerBuilder('my-bundle-mcp', '1.0.0'); |
| 175 | +$builder->addTool([MyTool::class, 'execute']); |
| 176 | +$server = $builder->build(); |
| 177 | +``` |
| 178 | + |
| 179 | +### Step 4: Create Controller |
| 180 | + |
| 181 | +Route the controller under `/pimcore-mcp/<bundle-name>`: |
| 182 | + |
| 183 | +```php |
| 184 | +use Mcp\Server; |
| 185 | +use Mcp\Server\Transport\StreamableHttpTransport; |
| 186 | +use Symfony\Component\Routing\Attribute\Route; |
| 187 | +
|
| 188 | +final readonly class McpController |
| 189 | +{ |
| 190 | + public function __construct( |
| 191 | + private Server $server, |
| 192 | + private HttpMessageFactoryInterface $httpMessageFactory, |
| 193 | + private HttpFoundationFactoryInterface $httpFoundationFactory, |
| 194 | + private ResponseFactoryInterface $responseFactory, |
| 195 | + private StreamFactoryInterface $streamFactory |
| 196 | + ) {} |
| 197 | +
|
| 198 | + #[Route( |
| 199 | + path: '/pimcore-mcp/my-bundle', |
| 200 | + name: 'my_bundle_mcp', |
| 201 | + methods: ['POST', 'GET'] |
| 202 | + )] |
| 203 | + public function handle(Request $request): Response |
| 204 | + { |
| 205 | + $transport = new StreamableHttpTransport( |
| 206 | + $this->httpMessageFactory->createRequest($request), |
| 207 | + $this->responseFactory, |
| 208 | + $this->streamFactory |
| 209 | + ); |
| 210 | +
|
| 211 | + return $this->httpFoundationFactory->createResponse( |
| 212 | + $this->server->run($transport) |
| 213 | + ); |
| 214 | + } |
| 215 | +} |
| 216 | +``` |
| 217 | + |
| 218 | +The `pimcore_mcp` firewall automatically handles authentication for any route matching `^/pimcore-mcp/`. No custom auth code |
| 219 | +is needed in your bundle. |
| 220 | + |
| 221 | +### Step 5: Get the Current User (Optional) |
| 222 | + |
| 223 | +If your tools need the authenticated user, inject `TokenStorageInterface`: |
| 224 | + |
| 225 | +```php |
| 226 | +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; |
| 227 | +
|
| 228 | +final readonly class MyTool |
| 229 | +{ |
| 230 | + public function __construct( |
| 231 | + private TokenStorageInterface $tokenStorage |
| 232 | + ) {} |
| 233 | +
|
| 234 | + #[McpTool(name: 'my_tool', description: '...')] |
| 235 | + public function execute(): CallToolResult |
| 236 | + { |
| 237 | + $user = $this->tokenStorage->getToken()?->getUser(); |
| 238 | + // $user is Pimcore\Security\User\User |
| 239 | + // $user->getUser() returns Pimcore\Model\User |
| 240 | + } |
| 241 | +} |
| 242 | +``` |
| 243 | + |
| 244 | +Or use `SecurityServiceInterface::getCurrentUser()` which works with both session and PAT auth. |
0 commit comments