Skip to main content
Version: Next

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​

CallerCredentialAuthenticator
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 cookiePimcore 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.

OrderAuthenticatorTriggerUse case
1SessionBridgeAuthenticatorPimcore session cookie presentRequests that carry a Pimcore Studio session cookie
2McpAccessTokenAuthenticatorAuthorization: Bearer pmcp_…Internal: dynamically-issued, expiring, revocable per-chat-session tokens (Pimcore AI agent)
3OAuthAccessTokenAuthenticatorAuthorization: Bearer <JWT>External: clients using the embedded OAuth 2.1 server; inert unless OAuth is enabled
4PatAuthenticatorAuthorization: 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
Scopesmcp: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>"
}
}
}
}

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:

MethodPurpose
issue(int $userId, int $ttlSeconds, string $reference): stringMint 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): boolExtend 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): voidDelete the token for reference.
revokeByUser(int $userId): voidDelete all tokens for a user.
validate(string $token): ?ValidatedAccessTokenResolve a raw token to its { user, reference } (used by McpAccessTokenAuthenticator).

Semantics​

  • reference is your correlation key. An opaque string the bundle chooses - the Pimcore AI agent-server uses the chat session id. It is what refresh() / revoke() operate on, and it is exposed to authenticated tool code via the _mcp_token_reference request attribute.
  • One live token per reference. issue() deletes any existing token for the same reference before creating the new one, so re-issuing rotates the token rather than accumulating rows.
  • TTL is a sliding window. refresh() moves the expiry to now + 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​

AspectValue
Prefixpmcp_ (McpAccessTokenService::TOKEN_PREFIX)
Entropy32 random bytes, hex-encoded
At restSHA-256 hash only, in table bundle_studio_mcp_access_token (token_hash, user_id, reference, expires_at, created_at)
Expiryexpires_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\ResponseFactoryInterface
  • Psr\Http\Message\StreamFactoryInterface
  • Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface
  • Symfony\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. See pimcore-agent-bundle for 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 the studio_mcp_access_token_gc maintenance 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 with revokeByUser(). 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.