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.
Credentials at a glance
Four kinds of credential reach the MCP endpoints. They differ in who they stand for, who issues them, how long they live and how they end.
| Credential | Stands for | Issued by | Expiry | Revoked or ended by |
|---|---|---|---|---|
OAuth authorization_code token (JWT, Authorization: Bearer <jwt>) | A person who signed in and consented | The embedded OAuth server at /pimcore-oauth/token, after the consent screen | oauth.access_token_ttl (default 3600 s); a refresh token only if the client registered refresh_token (refresh_token_ttl, default 30 days) | Revoked through its stored token record; a deactivated user is refused at the next request (isValidUser) |
OAuth client_credentials token (JWT) | A service user | POST /pimcore-oauth/token with the secret of the client bound to the service user. Off unless oauth.client_credentials.enabled is true (default false) | oauth.client_credentials.access_token_ttl (default 900 s, minimum 60). No refresh token | Deactivation is refused at the next request. Deleting the service user revokes its tokens by jti. Rotating the secret does not revoke issued tokens; they expire |
MCP access token (Bearer pmcp_…) | An AI agent session, or a service user until the grant above is on | McpAccessTokenServiceInterface::issue() for agent sessions; for service users the Studio API POST /pimcore-studio/api/service-user/{id}/mcp-token or the console command studio-backend-bundle:service-user:mcp-token | Bounded TTL. For service users 1 to 90 days, default 30 | DELETE /pimcore-studio/api/service-user/{id}/mcp-token or --revoke; revoke() and revokeByUser(); deactivation and deletion of the service user |
Static personal access token (pimcore_studio_backend.mcp.authentication.tokens) | An external MCP client, configured by hand | Written into the YAML configuration | None; valid until removed from the configuration | Remove it from the configuration. Deprecated since 2026.4, removed in 2027.1. No new feature accepts static PATs |
Service users have no password and no interactive login, so their credentials are the OAuth client secret and the MCP access tokens above. See Service Users for the API and the console command.
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, …), deprecated | 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. See OAuth-Protected Applications for the contracts and the blueprint.
The audience it validates against is the protected resource registered for the endpoint being called, not a
path this bundle derives. The endpoints behind this firewall belong to different bundles, so the most specific
registered resource covering the request is what the token is held to, and its URI is built from the configured
issuer, never from the request host. The bundle's own /pimcore-mcp resource covers every endpoint under it
that registers nothing more specific; servers managed through
MCP Server Management each register their own, so a token obtained for one
server, or for another application, is refused at the others. Scopes are advertised but not yet enforced.
See OAuth 2.1 Authorization Server for enabling and configuring the server.
PatAuthenticator (external clients) - deprecated
Deprecated since 2026.4, removed in 2027.1. Static Personal Access Tokens in
pimcore_studio_backend.mcp.authentication.tokenskeep working on 2026.x. Each authenticated request through this authenticator raises a deprecation, and the configuration node carries the same deprecation (visible inbin/console debug:config pimcore_studio_backend). Migrate before 2027.1:
- Integrations and pipelines: issue an MCP access token for a dedicated Pimcore user with
McpAccessTokenServiceInterface::issue(), which has a bounded TTL, and refresh or re-issue it from the integration. Use a dedicated user, not a person's account.- People using an MCP client: connect through OAuth (
authorization_code, see the OAuth Server) so the client receives tokens for the signed-in user instead of a shared secret.No new feature accepts static PATs.
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:
# DEPRECATED since 2026.4, removed in 2027.1: static PATs. Migrate to MCP access tokens or OAuth.
# 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.