MCP Server Infrastructure (Experimental)
Pimcore Studio Backend provides shared infrastructure for Model Context Protocol (MCP) servers across bundles. This includes a dedicated security firewall, PSR-7/PSR-17 bridge services, and a multi-authenticator security system supporting both internal agent use and external MCP clients.
The pimcore_mcp Firewall
All MCP endpoints use the URL prefix /pimcore-mcp/ and are protected by a dedicated Symfony firewall (pimcore_mcp).
This firewall is stateless - each request authenticates independently, with no session migration or security token
persistence. It is separate from the pimcore_studio firewall to provide security isolation - MCP authentication cannot
leak to Studio Backend API routes and vice versa.
All authenticators resolve to a Pimcore User object, and all existing Pimcore permissions (workspace ACLs, user/role
permissions) apply automatically: if a user cannot edit a data object via Pimcore Studio, they cannot edit it via MCP
tools either. OAuth tokens carry mcp:read and mcp:write, but nothing compares a granted scope against an operation
- they are consent labels, and authorization remains the resolved user's own permissions.
Who calls MCP endpoints, and with which credential
| Caller | Credential | Authenticator |
|---|---|---|
| Pimcore AI agent-server (internal, per chat session) | Authorization: Bearer pmcp_… | McpAccessTokenAuthenticator |
| External MCP client (Claude Desktop, Cursor, …) | Authorization: Bearer <static PAT> | PatAuthenticator |
| Caller presenting a Pimcore Studio session cookie | Pimcore session cookie (PHPSESSID) | SessionBridgeAuthenticator |
The primary internal path is the MCP access token (Bearer pmcp_…): the Pimcore AI agent-server mints one dynamic,
expiring, revocable token per chat session (see Minting MCP access tokens) and sends it on
every /pimcore-mcp/ request. The bearer binds each request to a specific chat session and stays valid across
browser-session expiry, which matters for long-running agent runs. SessionBridgeAuthenticator handles requests that
instead carry a Pimcore Studio session cookie.
Authenticator chain
The firewall tries these authenticators in order. Each returns null on failure so the next one can try, and
each declines credentials it does not own, so the shapes do not overlap: pmcp_ bearers belong to
McpAccessTokenAuthenticator, JWT-shaped bearers to OAuthAccessTokenAuthenticator, anything else to
PatAuthenticator. When no authenticator claims the request, or the one that claimed it fails, the firewall's
entry_point produces the terminal response (see
Unauthenticated requests). PatAuthenticator answers 429 itself when a client
is throttled.
| Order | Authenticator | Trigger | Use case |
|---|---|---|---|
| 1 | SessionBridgeAuthenticator | Pimcore session cookie present | Requests that carry a Pimcore Studio session cookie |
| 2 | McpAccessTokenAuthenticator | Authorization: Bearer pmcp_… | Internal: dynamically-issued, expiring, revocable per-chat-session tokens (Pimcore AI agent) |
| 3 | OAuthAccessTokenAuthenticator | Authorization: Bearer <JWT> | External: clients using the embedded OAuth 2.1 server; inert unless OAuth is enabled |
| 4 | PatAuthenticator | Authorization: Bearer <other> | External: MCP clients using static Personal Access Tokens (Claude Desktop, Cursor, etc.) |
Unauthenticated requests
When the chain produces no authenticated user, the firewall's entry_point, McpAuthenticationEntryPoint,
writes the response. It always answers 401 with {"error": "unauthorized"}.
With the embedded OAuth server enabled it adds the RFC 9728 discovery challenge, which is how a standards-based client learns where to authenticate:
WWW-Authenticate: Bearer resource_metadata="https://host/.well-known/oauth-protected-resource/pimcore-mcp"
The URL always names the MCP base resource, never the sub-path that was called, because that base is what
OAuthAccessTokenAuthenticator validates every token's audience against. The challenge carries no scope
hint: RFC 6750 makes it optional, and the metadata document it points at already advertises
scopes_supported from the resource itself, so a second copy in the header could only
disagree with it. The bundle registers that resource
itself, at <oauth.issuer>/pimcore-mcp, so the metadata document resolves without any configuration. See
Accepting tokens at the MCP endpoints on the OAuth server page.
With OAuth disabled the header is omitted entirely and the response is a plain 401, so behaviour is
unchanged for installations that never opted in.
OAuth protected resource
The MCP endpoints are an OAuth protected resource, i.e. a token audience. The bundle contributes it
automatically whenever pimcore_studio_backend.oauth.enabled is true, so there is nothing to configure:
| Resource URI | <oauth.issuer>/pimcore-mcp |
| Scopes | mcp:read, mcp:write |
| Authorization server | <oauth.issuer> |
The URI is the MCP base. Every /pimcore-mcp/... request is validated against that one audience, not
against the sub-path that was called, which is also why the 401 challenge above points at the base.
It is built from the configured issuer rather than from the request. Host is caller-supplied unless
framework.trusted_hosts is set, so a resource named after it would let a caller declare their own host as an
audience, obtain a token stamped with it, and then satisfy the audience check by replaying the same header.
Both the contribution and OAuthAccessTokenAuthenticator read oauth.issuer, so they agree on a value the
caller cannot choose, and a reverse proxy changes nothing.
Those scopes are what put mcp:read and mcp:write in the server's catalogue: a scope exists because a
resource supports it. Nothing compares a granted scope against an operation, though, so treat them as consent
labels rather than permissions; MCP authorization is the resolved user's own Pimcore permissions plus
per-server access.
To override it, declare the same URI under oauth.resources. A configured entry replaces the contributed
one rather than adding a second:
pimcore_studio_backend:
oauth:
resources:
# Exactly <oauth.issuer>/pimcore-mcp, no trailing slash. An entry whose URI
# differs does not override anything: it adds an unrelated second resource
# that nothing validates against, and the contributed one stays as it is.
- uri: 'https://pimcore.example.com/pimcore-mcp'
scopes_supported: ['mcp:read']
authorization_servers: ['https://pimcore.example.com']
See OAuth 2.1 Authorization Server for enabling the server, and OAuth-Protected Applications to do the same for your own bundle's endpoints.
McpAccessTokenAuthenticator (primary internal)
Validates a pmcp_-prefixed bearer token via McpAccessTokenService (DB-backed, hashed at rest, TTL-bounded,
revocable). It never reads the PHP session. On success, the validated token's reference (the chat session id) is
stashed on the request attributes (_mcp_token_reference) so trusted downstream code can use it instead of any
forge-able header. On failure it returns null, so the firewall falls through to PatAuthenticator.
The studio-backend bundle owns both validation (McpAccessTokenAuthenticator) and the issuance/refresh/revoke
primitives. Consuming bundles (e.g. pimcore-agent-bundle) call those primitives to mint tokens for their own MCP
servers - see Minting MCP access tokens.
OAuthAccessTokenAuthenticator (OAuth 2.1 bearer)
Authenticates a JWT access token issued by the embedded OAuth 2.1 authorization
server (Authorization: Bearer <jwt>). It is
additive to the chain: it only claims JWT-shaped bearers, declines the pmcp_ prefix (owned by
McpAccessTokenAuthenticator), and stays inert unless the OAuth server is enabled. It validates the
token's signature, expiry and revocation status and resolves the Pimcore user.
On failure it returns null rather than a response, but no later authenticator picks the request up:
PatAuthenticator declines JWT-shaped bearers by design, so a rejected OAuth token is not retried as a
PAT. The request finishes unauthenticated and the entry point answers 401 with
the discovery challenge. That is deliberate, and it is also why an expired or revoked OAuth token cannot
consume the PAT brute-force throttle bucket.
MCP is one application of the OAuth server, not its purpose. The same contracts protect Datahub Simple REST, and any bundle can use them for its own endpoints. Tokens are bound to the resource they were requested for, so a token obtained for another application is refused here; scopes are advertised but not yet enforced. See OAuth-Protected Applications for the contracts and the blueprint, and OAuth 2.1 Authorization Server for enabling the server.
PatAuthenticator (external clients)
External MCP clients authenticate with static Personal Access Tokens configured in YAML. It deliberately
declines two shapes it does not own: any Bearer pmcp_… token (handled by McpAccessTokenAuthenticator) and
any JWT-shaped bearer, i.e. three base64url segments separated by dots (handled by
OAuthAccessTokenAuthenticator). Both exclusions are unconditional, including when the OAuth server is
disabled, so a configured PAT must not itself look like a JWT.
# config/config.yaml or config/packages/pimcore_studio_backend.yaml
pimcore_studio_backend:
mcp:
authentication:
tokens:
admin:
- '%env(MCP_TOKEN_ADMIN)%'
editor_user:
- '%env(MCP_TOKEN_EDITOR)%'
Each key is a Pimcore username, and the value is a list of accepted tokens for that user. Tokens can reference
environment variables to keep secrets out of YAML files. The PatAuthenticator extracts the bearer token from the
Authorization header, looks up the username in the token map, loads the Pimcore User, validates it is active, and
creates a SelfValidatingPassport.
Client configuration example (Claude Desktop / Cursor):
{
"mcpServers": {
"pimcore": {
"url": "https://your-pimcore.com/pimcore-mcp/agent/pimcore-data-objects-read",
"headers": {
"Authorization": "Bearer <your-token>"
}
}
}
}
SessionBridgeAuthenticator (session cookie)
Authenticates an MCP request against an existing Pimcore Studio session. It reads _security_pimcore_admin from the PHP
session (cross-context) via AuthenticationResolverInterface::authenticateSession(), validates that the user exists and
is active, and creates a SelfValidatingPassport. It returns null on failure so the next authenticator can try.
It applies to requests that arrive with a Studio session cookie. The agent-server's own MCP calls use the pmcp_…
bearer instead (see the credential table above).
Minting MCP access tokens
McpAccessTokenService (behind McpAccessTokenServiceInterface) is the API a bundle uses to mint and manage dynamic
MCP access tokens for its own MCP servers. Inject the interface and call:
| Method | Purpose |
|---|---|
issue(int $userId, int $ttlSeconds, string $reference): string | Mint a token for a user, bound to reference, valid for ttlSeconds. Returns the raw pmcp_… token - the only time it is available in clear text. |
refresh(string $reference, int $ttlSeconds): bool | Extend the live token for reference by a fresh ttlSeconds window. Returns false if no live token exists or the user is no longer valid. |
revoke(string $reference): void | Delete the token for reference. |
revokeByUser(int $userId): void | Delete all tokens for a user. |
validate(string $token): ?ValidatedAccessToken | Resolve a raw token to its { user, reference } (used by McpAccessTokenAuthenticator). |
Semantics
referenceis your correlation key. An opaque string the bundle chooses - the Pimcore AI agent-server uses the chat session id. It is whatrefresh()/revoke()operate on, and it is exposed to authenticated tool code via the_mcp_token_referencerequest attribute.- One live token per
reference.issue()deletes any existing token for the samereferencebefore creating the new one, so re-issuing rotates the token rather than accumulating rows. - TTL is a sliding window.
refresh()moves the expiry tonow + ttlSeconds; a caller keeps a long-running session alive by refreshing on a timer. - The raw token is returned once. Only its SHA-256 hash is stored, so a lost token cannot be recovered - only re-issued.
- Validation re-checks the user.
validate()rejects a token whose user is no longer valid, so deactivating or deleting a user immediately stops their tokens working, independent of expiry.
Token format and storage
| Aspect | Value |
|---|---|
| Prefix | pmcp_ (McpAccessTokenService::TOKEN_PREFIX) |
| Entropy | 32 random bytes, hex-encoded |
| At rest | SHA-256 hash only, in table bundle_studio_mcp_access_token (token_hash, user_id, reference, expires_at, created_at) |
| Expiry | expires_at (unix seconds); expired rows are pruned by the studio_mcp_access_token_gc maintenance task |
For a worked example of a mint / refresh / re-mint / revoke policy on top of these primitives - including when to mint vs. extend and how a client paces refreshes - see the Pimcore Agent Bundle's MCP Integration → Token lifecycle documentation.
PSR-7/PSR-17 Bridge Services
Studio Backend Bundle provides the PSR-7/PSR-17 bridge services required by MCP controllers globally. Bundles that implement MCP servers do not need to register these services themselves:
Psr\Http\Message\ResponseFactoryInterfacePsr\Http\Message\StreamFactoryInterfaceSymfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterfaceSymfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface
These are defined in config/mcp.yaml and available for autowiring in any bundle.
Configuration Reference
pimcore_studio_backend:
mcp:
authentication:
tokens:
# Map: Pimcore username => list of bearer tokens (external PATs)
<username>:
- '<token-string-or-env-ref>'
The firewall is automatically configured by the bundle extension. To enable it, add this to your
config/packages/security.yaml (see also Installation):
security:
firewalls:
pimcore_mcp: '%pimcore_studio_backend.mcp_firewall_settings%'
access_control:
- { path: ^/pimcore-mcp/, roles: ROLE_PIMCORE_USER }
No manual firewall configuration beyond this is needed - the parameter contains the full firewall definition including
the authenticator chain, user provider, and stateless flag. Dynamic MCP access tokens (Bearer pmcp_…) need no
configuration here; they are issued at runtime by the consuming bundle.
Implementing an MCP Server in a Bundle
Step 1: Create MCP Tool Classes
Use the mcp/sdk package attributes to define tools:
use Mcp\Attribute\McpTool;
use Mcp\Attribute\Schema;
use Mcp\Types\CallToolResult;
use Mcp\Types\TextContent;
final readonly class MyTool
{
#[McpTool(
name: 'my_tool_name',
description: 'What this tool does'
)]
public function execute(
#[Schema(description: 'Parameter description')]
string $param
): CallToolResult {
// Tool implementation
return new CallToolResult(
[new TextContent('Result')]
);
}
}
Step 2: Register Tools as Services
# config/services.yaml (in your bundle)
services:
My\Bundle\Mcp\Tool\MyTool: ~
Step 3: Create the MCP Server
Build a server using the SDK, referencing your tool classes:
use Mcp\Server;
use Mcp\ServerBuilder;
$builder = new ServerBuilder('my-bundle-mcp', '1.0.0');
$builder->addTool([MyTool::class, 'execute']);
$server = $builder->build();
Step 4: Create Controller
Route the controller under /pimcore-mcp/<bundle-name>:
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
use Symfony\Component\Routing\Attribute\Route;
final readonly class McpController
{
public function __construct(
private Server $server,
private HttpMessageFactoryInterface $httpMessageFactory,
private HttpFoundationFactoryInterface $httpFoundationFactory,
private ResponseFactoryInterface $responseFactory,
private StreamFactoryInterface $streamFactory
) {}
#[Route(
path: '/pimcore-mcp/my-bundle',
name: 'my_bundle_mcp',
methods: ['POST', 'GET']
)]
public function handle(Request $request): Response
{
$transport = new StreamableHttpTransport(
$this->httpMessageFactory->createRequest($request),
$this->responseFactory,
$this->streamFactory
);
return $this->httpFoundationFactory->createResponse(
$this->server->run($transport)
);
}
}
The pimcore_mcp firewall automatically handles authentication for any route matching ^/pimcore-mcp/. No custom auth
code is needed in your bundle.
Step 5: Get the Current User (Optional)
If your tools need the authenticated user, inject TokenStorageInterface:
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
final readonly class MyTool
{
public function __construct(
private TokenStorageInterface $tokenStorage
) {}
#[McpTool(name: 'my_tool', description: '...')]
public function execute(): CallToolResult
{
$user = $this->tokenStorage->getToken()?->getUser();
// $user is Pimcore\Security\User\User
// $user->getUser() returns Pimcore\Model\User
}
}
Or use SecurityServiceInterface::getCurrentUser() which works with all authenticators.
Operational notes
- Transport security. Dynamic MCP access tokens (
Bearer pmcp_…) and static PATs are credentials. Serve Studio over HTTPS in production; over plain HTTP these tokens are sniffable on the wire. - Log redaction. Tools that log raw request headers must redact
Authorization. Seepimcore-agent-bundlefor the Fastify (agent-server) and Symfony (bundle) configuration. - Token lifecycle. MCP access tokens expire after the consuming bundle's configured TTL (default 2h for
pimcore-agent-bundle); expired rows are pruned by thestudio_mcp_access_token_gcmaintenance task. A token also stops working the moment its user is deactivated or removed -validate()re-checks the user on every request - and a bundle can drop a user's tokens explicitly withrevokeByUser(). See Minting MCP access tokens.
Throttling guessed credentials
Guesses at a static PAT are throttled per client IP: 5 failures per 5 minutes, after which the client receives
429 Too Many Requests with a Retry-After header in seconds. An ordinary authentication failure stays 401. Honour
Retry-After rather than polling.
Only unrecognised tokens are counted. PatAuthenticator puts the resolved username on the passport when a token
matches the configured token map and a shared placeholder when it matches nothing, and McpLoginRateLimiter only ever
hands out a bucket for the placeholder. A recognised credential is therefore neither blocked nor counted.
Dynamic tokens (pmcp_…) and session bridge requests are not throttled at all. A dynamic token is 32 random
bytes, so guessing one is not a reachable attack; its protection is entropy, a short TTL and revokeByUser() - see
Minting MCP access tokens.
Send one credential per request. The firewall runs every authenticator that matches, so a request carrying an
unrecognised PAT alongside a valid session cookie is judged on the PAT. Such a request already failed with 401
before throttling existed; once the budget is gone it fails with 429 instead.
The client IP comes from Request::getClientIp(). Configure framework.trusted_proxies behind a reverse proxy, or
every client shares one bucket keyed on the proxy's address.
To change the threshold, redefine the limiter:
# config/packages/framework.yaml
framework:
rate_limiter:
studio_mcp_login:
policy: 'fixed_window'
limit: 10
interval: '5 minutes'
To change anything else about the firewall, redefine pimcore_studio_backend.mcp_firewall_settings in full. It is
substituted as a whole value, so a partial override under security.firewalls.pimcore_mcp silently drops the pattern,
provider and authenticators. The bundle only sets the parameter when it is not already defined:
# config/packages/security.yaml (or any file loaded before the bundle's extension runs)
parameters:
pimcore_studio_backend.mcp_firewall_settings:
pattern: '^/pimcore-mcp/'
user_checker: Pimcore\Security\User\UserChecker
provider: pimcore_studio_backend
stateless: true
login_throttling:
limiter: Pimcore\Bundle\StudioBackendBundle\Security\RateLimiter\McpLoginRateLimiterInterface
entry_point: Pimcore\Bundle\StudioBackendBundle\Security\EntryPoint\McpAuthenticationEntryPoint
custom_authenticators:
- Pimcore\Bundle\StudioBackendBundle\Security\Authenticator\Mcp\SessionBridgeAuthenticator
- Pimcore\Bundle\StudioBackendBundle\Security\Authenticator\Mcp\McpAccessTokenAuthenticator
# Must precede PatAuthenticator: it claims JWT-shaped bearers and yields to Pat
# for opaque ones. Reordering these two breaks OAuth bearer authentication.
- Pimcore\Bundle\StudioBackendBundle\Security\Authenticator\Mcp\OAuthAccessTokenAuthenticator
- Pimcore\Bundle\StudioBackendBundle\Security\Authenticator\Mcp\PatAuthenticator
That is the default verbatim. Dropping OAuthAccessTokenAuthenticator removes OAuth bearer authentication from
the MCP endpoints, and dropping entry_point removes the RFC 9728 401 challenge, so a standards-based client
has no way to discover where to authenticate. Neither produces an error; both simply stop working.
Request rate limiting
MCP endpoints have their own limiter, studio_mcp_general: 3000 requests per minute per client IP, reported
through X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, and answered with 429 on overflow.
It is separate from studio_api_general because MCP carries machine traffic - a single agent server can serve every
chat in an installation from one address - while the Studio budget is sized for a browser UI. Retune it the same way.