Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8910e00
[MCP] Add MCP server definitions with a location-aware config repository
wwidergoldpimcore Aug 24, 2026
e4a1ecf
[MCP] Let MCP tools self-register via a tag and collect them in a reg…
wwidergoldpimcore Aug 24, 2026
824eb39
[MCP] Serve configured MCP servers over HTTP with per-server access a…
wwidergoldpimcore Aug 25, 2026
1d0045c
[MCP] Add Studio-API CRUD for MCP servers and a tool catalogue
wwidergoldpimcore Aug 25, 2026
8c2a84f
[MCP] Add migration registering the mcp_servers permission on existin…
wwidergoldpimcore Aug 26, 2026
d13026b
[MCP] Give MCP servers a read/write sharing grid instead of flat use-…
wwidergoldpimcore Aug 26, 2026
6d24200
[MCP] Document the embedded OAuth 2.1 authorization server
wwidergoldpimcore Aug 27, 2026
ac55d17
[MCP] Document MCP server management and tool authoring
wwidergoldpimcore Aug 27, 2026
b65d38c
[MCP] Unify the tool contract on the SDK-native #[McpTool] shape
wwidergoldpimcore Aug 28, 2026
f47aee8
[MCP] Key MCP server sharing by user/role name instead of id
wwidergoldpimcore Aug 28, 2026
8a74523
[MCP] Docs: drop pre-registered clients / client_credentials from the…
wwidergoldpimcore Aug 28, 2026
b136ac8
[MCP] Rework server access into view/access/edit capabilities
wwidergoldpimcore Aug 28, 2026
930e0b5
[MCP] Docs: restore pre-registered clients (public-only) on the OAuth…
wwidergoldpimcore Aug 31, 2026
e529a61
[MCP] Fix file-based server config rejecting a username owner and grants
wwidergoldpimcore Aug 31, 2026
34fc6eb
[MCP] Give the server owner implicit read/edit, explicit access
wwidergoldpimcore Sep 2, 2026
2f94995
[MCP] Docs: update server management to the three-capability model
wwidergoldpimcore Sep 2, 2026
6052cf0
[OAuth] Open the resource-server contracts as public API
fashxp Aug 31, 2026
bd24966
[OAuth] Document the server as a platform capability, not an MCP feature
fashxp Aug 31, 2026
bf28dd5
[OAuth] Honour the canonicalisation promise and fail closed on bad keys
fashxp Aug 31, 2026
e1b43f1
[OAuth] Bind tokens to a resource and make the scope catalogue extens…
fashxp Aug 31, 2026
ae0d4c5
[OAuth] Derive the MCP resource URI per server, not per prefix
fashxp Aug 31, 2026
09d86e0
[OAuth] Require a resource and carry the binding in the token record
fashxp Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"php": "~8.4.0 || ~8.5.0",
"league/csv": "^9.27",
"league/oauth2-server": "^9.0",
"mcp/sdk": "^0.7",
"nesbot/carbon": "^3.10.0",
"pimcore/static-resolver-bundle": "^2026.2.0",
"pimcore/generic-data-index-bundle": "^2026.2.0",
Expand Down
70 changes: 70 additions & 0 deletions config/mcp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,73 @@ services:
Pimcore\Bundle\StudioBackendBundle\Mcp\Tool\McpToolErrorHandler: ~
Pimcore\Bundle\StudioBackendBundle\Mcp\Tool\McpToolErrorHandlerInterface:
alias: Pimcore\Bundle\StudioBackendBundle\Mcp\Tool\McpToolErrorHandler

# --- MCP server definitions ---
# Location-aware storage for configured MCP servers. $serverConfigurations
# (shipped seed) and $storageConfig (write/read target) are set from bundle
# config in the extension.
Pimcore\Bundle\StudioBackendBundle\Mcp\Repository\McpServerConfigRepositoryInterface:
class: Pimcore\Bundle\StudioBackendBundle\Mcp\Repository\McpServerConfigRepository
arguments:
$serverConfigurations: []
$storageConfig: []
$repository: null

# --- MCP tools ---
# Registry of SDK-native #[McpTool] services tagged with McpToolRegistry::TAG.
# McpToolPass reflects the tagged services and injects $toolMetadata + a service
# $toolLocator; the arguments below are placeholders it overrides.
Pimcore\Bundle\StudioBackendBundle\Mcp\Registry\McpToolRegistry:
autowire: false
arguments:
$toolMetadata: []
$toolLocator: null
Pimcore\Bundle\StudioBackendBundle\Mcp\Registry\McpToolRegistryInterface:
alias: Pimcore\Bundle\StudioBackendBundle\Mcp\Registry\McpToolRegistry

# Built-in tools (tagged so the registry collects them)
Pimcore\Bundle\StudioBackendBundle\Mcp\Tool\Builtin\PingTool:
tags: ['pimcore.studio_backend.mcp_tool']

# --- Serving configured MCP servers over HTTP ---
# PSR-16 view of the dedicated MCP session cache pool (pool declared in the
# extension's prepend()).
pimcore_studio_backend.mcp.session_cache:
class: Symfony\Component\Cache\Psr16Cache
arguments: ['@pimcore_studio_backend.mcp.session']

Pimcore\Bundle\StudioBackendBundle\Mcp\Server\McpServerFactory:
arguments:
$sessionCache: '@pimcore_studio_backend.mcp.session_cache'
Pimcore\Bundle\StudioBackendBundle\Mcp\Server\McpServerFactoryInterface:
alias: Pimcore\Bundle\StudioBackendBundle\Mcp\Server\McpServerFactory

Pimcore\Bundle\StudioBackendBundle\Mcp\Security\McpServerAccessResolver: ~
Pimcore\Bundle\StudioBackendBundle\Mcp\Security\McpServerAccessResolverInterface:
alias: Pimcore\Bundle\StudioBackendBundle\Mcp\Security\McpServerAccessResolver

# All MCP controllers (the runtime serving endpoint + the Studio-API management
# controllers) are public service-controllers.
Pimcore\Bundle\StudioBackendBundle\Mcp\Controller\:
resource: '../src/Mcp/Controller'
public: true
tags: ['controller.service_arguments']

# --- Studio-API: MCP server management + tool catalogue ---
Pimcore\Bundle\StudioBackendBundle\Mcp\Service\McpServerConfigurationService:
arguments:
$issuer: '%pimcore_studio_backend.oauth.issuer%'
Pimcore\Bundle\StudioBackendBundle\Mcp\Service\McpServerConfigurationServiceInterface:
alias: Pimcore\Bundle\StudioBackendBundle\Mcp\Service\McpServerConfigurationService

Pimcore\Bundle\StudioBackendBundle\Mcp\Service\McpToolCatalogueService: ~
Pimcore\Bundle\StudioBackendBundle\Mcp\Service\McpToolCatalogueServiceInterface:
alias: Pimcore\Bundle\StudioBackendBundle\Mcp\Service\McpToolCatalogueService

Pimcore\Bundle\StudioBackendBundle\Mcp\Hydrator\McpServerHydrator: ~
Pimcore\Bundle\StudioBackendBundle\Mcp\Hydrator\McpServerHydratorInterface:
alias: Pimcore\Bundle\StudioBackendBundle\Mcp\Hydrator\McpServerHydrator

Pimcore\Bundle\StudioBackendBundle\Mcp\Hydrator\McpToolItemHydrator: ~
Pimcore\Bundle\StudioBackendBundle\Mcp\Hydrator\McpToolItemHydratorInterface:
alias: Pimcore\Bundle\StudioBackendBundle\Mcp\Hydrator\McpToolItemHydrator
13 changes: 13 additions & 0 deletions config/oauth.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ services:
Pimcore\Bundle\StudioBackendBundle\Security\Authenticator\Mcp\OAuthAccessTokenAuthenticator:
arguments:
$enabled: false
$issuer: '%pimcore_studio_backend.oauth.issuer%'

# Entry point for the pimcore_mcp firewall (401 + RFC 9728 challenge).
# $oauthEnabled is set from config in the extension.
Expand Down Expand Up @@ -108,6 +109,18 @@ services:
arguments:
$enabled: false

# Scope catalogue. Any bundle exposing protected resources contributes its own
# identifiers by tagging a ScopeProviderInterface service.
Pimcore\Bundle\StudioBackendBundle\OAuth\Registry\ScopeRegistry:
arguments:
$providers: !tagged_iterator pimcore_studio_backend.oauth.scope_provider

Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\ScopeRegistryInterface:
alias: Pimcore\Bundle\StudioBackendBundle\OAuth\Registry\ScopeRegistry

Pimcore\Bundle\StudioBackendBundle\OAuth\Scope\McpScopeProvider:
tags: ['pimcore_studio_backend.oauth.scope_provider']

Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Repository\ScopeRepository: ~

Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Repository\AccessTokenRepository:
Expand Down
11 changes: 11 additions & 0 deletions config/pimcore/routing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,14 @@ pimcore_studio_oauth_register:
path: /pimcore-oauth/register
controller: Pimcore\Bundle\StudioBackendBundle\OAuth\Controller\ClientRegistrationController
methods: [POST]

# MCP server endpoint: one configured server per URL, under the shared pimcore_mcp
# firewall (so it accepts the OAuth bearer). Explicit (not attribute) so it is not
# prefixed with the Studio API url prefix, and namespaced under /studio/ so it does
# not collide with other bundles' /pimcore-mcp/ routes.
pimcore_studio_mcp_server:
path: /pimcore-mcp/studio/{server}
controller: Pimcore\Bundle\StudioBackendBundle\Mcp\Controller\McpServerController
methods: [GET, POST, DELETE]
requirements:
server: '[a-z0-9-]+'
244 changes: 244 additions & 0 deletions doc/02_Installation_and_Configuration/06_OAuth_Server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
---
title: OAuth 2.1 Authorization Server
description: Embedded, opt-in OAuth 2.1 authorization server for authenticating MCP and other API clients against Pimcore.
---

# OAuth 2.1 Authorization Server (Experimental)

The Studio Backend Bundle ships an embedded **OAuth 2.1 authorization server**. It lets standards-based
clients obtain a bearer token and call Pimcore endpoints on behalf of a Pimcore user, without static
credentials.

This page covers running the authorization server: enabling it, key material, endpoints, and onboarding
clients. It issues tokens and does not care which endpoints they are presented to. Accepting those tokens is
a separate role, filled by any bundle that makes its endpoints a *resource server*. To build one, see
[OAuth-Protected Applications](../04_Development_Details/07_OAuth_Protected_Applications.md).

It is **opt-in** (off by default) and deliberately **isolated from your application's global security
configuration**: enabling it adds a self-contained set of routes and does not change how the rest of your
firewalls behave.

> **Experimental.** The feature is under active development; configuration keys and behavior may change
> between minor versions. Enable it consciously and pin the bundle version.

Two applications accept its tokens today: the bundle's own
[MCP servers](../04_Development_Details/08_MCP_Server.md), where OAuth is one of several accepted
credentials, and Data Hub Simple REST. Neither is privileged; both build on the same public contracts.

## What it provides

- **Discovery** — Authorization Server Metadata ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)) and
Protected Resource Metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)).
- **Authorization Code grant with PKCE** ([RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)) — the `S256`
method is **required**; `plain` is rejected.
- **Refresh tokens**.
- Three ways to onboard clients, all resolving to **public** clients (PKCE, no secret): **pre-registered**
clients declared in config, optional **Dynamic Client Registration**
([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)), and optional **Client ID Metadata Documents**. There are
no confidential/service clients and no Client Credentials grant. Non-interactive machine access uses
whatever static credential the target application supports, for example the
[MCP token authenticator](../04_Development_Details/08_MCP_Server.md) (PAT).

## Enabling

The minimum configuration is the master switch, an issuer, and signing keys:

```yaml
# config/packages/pimcore_studio_backend.yaml
pimcore_studio_backend:
oauth:
enabled: true
# Issuer identifier advertised in metadata and stamped on tokens.
# If null, it is derived from the incoming request — set it explicitly in production.
issuer: 'https://pimcore.example.com'
keys:
private_key: '%env(OAUTH_PRIVATE_KEY)%'
public_key: '%env(OAUTH_PUBLIC_KEY)%'
passphrase: '%env(OAUTH_KEY_PASSPHRASE)%'
encryption_key: '%env(OAUTH_ENCRYPTION_KEY)%'
```

> Reference key material via environment variables or Symfony secrets. **Never commit keys.**

### Generating keys

The signing keys are an RSA key pair; the encryption key is a random string used for authorization codes and
refresh tokens:

```bash
# RSA signing key pair (add -passout pass:... if you set a passphrase)
openssl genrsa -out oauth-private.key 2048
openssl rsa -in oauth-private.key -pubout -out oauth-public.key

# Encryption key (e.g. 32 random bytes, base64-encoded)
php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'
```

`private_key`/`public_key` accept either a file path or the key contents.

## Exposing the endpoints

The OAuth routes live at the **web root** — outside the `%pimcore_studio_backend.url_prefix%` (Studio API) and
outside the `pimcore_mcp` firewall. Discovery, token, and (if enabled) registration must be **publicly
reachable**; the authorize endpoint needs a logged-in Studio session for login/consent.

Make sure your `security.access_control` allows them:

```yaml
security:
access_control:
# Public discovery + token + dynamic registration
- { path: '^/\.well-known/oauth-', roles: PUBLIC_ACCESS }
- { path: '^/pimcore-oauth/(token|register)$', roles: PUBLIC_ACCESS }
# ... your existing pimcore_studio / pimcore_mcp rules ...
```

For clients to actually *use* the token against an MCP server, the `pimcore_mcp` firewall must be enabled — see the
[MCP firewall setup](./README.md) (the *Optional: MCP firewall* step). Its authenticator chain includes an OAuth
bearer authenticator that validates these tokens.

## Endpoints

| Path | Method | Purpose |
|------|--------|---------|
| `/.well-known/oauth-authorization-server` | GET | Authorization Server Metadata (RFC 8414) — public discovery |
| `/.well-known/oauth-protected-resource{/path}` | GET | Protected Resource Metadata (RFC 9728) — advertises the audience + auth server for a resource |
| `/pimcore-oauth/authorize` | GET | Browser entry point; redirects to the Studio consent UI (`oauth.consent_path`) |
| `/pimcore-oauth/token` | POST | Token endpoint (authorization_code, refresh_token) |
| `/pimcore-oauth/register` | POST | Dynamic Client Registration (RFC 7591) — returns `404` unless enabled |

## How a client authenticates

The Authorization Code + PKCE flow, end to end:

1. The client reads `/.well-known/oauth-authorization-server` to discover the endpoints.
2. It sends the user to `/pimcore-oauth/authorize` with a PKCE `code_challenge` (`S256`). The endpoint
redirects to the Studio consent UI (`oauth.consent_path`), where the user logs in and approves.
3. On approval the client receives an authorization code and exchanges it at `/pimcore-oauth/token`, presenting
the PKCE `code_verifier`. It gets an access token (a signed JWT) and, optionally, a refresh token.
4. The client calls the MCP endpoint (`/pimcore-mcp/studio/{server}`) with `Authorization: Bearer <jwt>`. The
`pimcore_mcp` firewall's `OAuthAccessTokenAuthenticator` validates the token and resolves the Pimcore user.

A `401` from a protected resource carries a `WWW-Authenticate` challenge pointing at the resource's metadata, so
a compliant client can discover where to authenticate.

> **Scopes.** Scopes (e.g. `mcp:read`) are advertised in metadata and carried on tokens, but authorization is
> enforced by **Pimcore user permissions** (and, for MCP servers, per-server access) rather than by scope
> checks. Treat scopes as descriptive for now.

## Onboarding clients

Use one (or several) of the following. All three yield **public** clients that authenticate a logged-in
Pimcore user via the Authorization Code + PKCE flow — none carry a secret.

### Pre-registered clients

Declare known clients directly in config — first-party clients you control, or any client that supports
neither of the self-registration mechanisms below. Each entry is a `client_id` (the map key) with an
allow-list of redirect URIs:

```yaml
pimcore_studio_backend:
oauth:
clients:
my-desktop-app:
name: 'My Desktop App'
redirect_uris:
- 'http://127.0.0.1:33418/callback'
- 'http://localhost:33418/callback'
```

Pre-registered clients are **public only** — there is no `secret`, `confidential`, or `service_user` field,
and no Client Credentials grant. They resolve **before** Client ID Metadata Documents and Dynamic Client
Registration, and work even when both of those are disabled — so they are the onboarding path for a
locked-down deployment that exposes no open registration endpoint.

### Dynamic Client Registration (RFC 7591)

Lets clients without prior credentials self-register at an **open, unauthenticated** endpoint. Opt-in:

```yaml
pimcore_studio_backend:
oauth:
dynamic_client_registration:
enabled: true
```

Enable it deliberately — the `/pimcore-oauth/register` endpoint becomes publicly writable and is advertised in
metadata.

### Client ID Metadata Documents

Instead of registering, a client presents an **HTTPS URL as its `client_id`**; the server fetches the client
metadata from that URL. Opt-in, with host allow-listing:

```yaml
pimcore_studio_backend:
oauth:
client_id_metadata_documents:
enabled: true
allowed_hosts: ['client.example.com']
allow_insecure: false # dev only; permits http/loopback URLs
cache_ttl: 300
```

## Protected resources (audiences)

Declare the endpoints that act as token audiences. Each becomes discoverable via Protected Resource Metadata.
Applications whose endpoints are only known at runtime register them programmatically instead, through
`ResourceRegistryInterface`.

A client names the resource it wants a token for with the RFC 8707 `resource` parameter on the authorization
request. An unknown resource is rejected; a known one is stamped onto the token as its `aud` and enforced
when that token is presented, so a token minted for one resource is refused at another. A client that sends
no `resource` gets an unbound token, which stays valid at every resource, so existing clients keep working.

```yaml
pimcore_studio_backend:
oauth:
resources:
- uri: 'https://pimcore.example.com/pimcore-mcp/studio/product-read'
scopes_supported: ['mcp:read']
authorization_servers: ['https://pimcore.example.com']
```

## Configuration reference

All keys live under `pimcore_studio_backend.oauth`.

| Key | Default | Purpose |
|-----|---------|---------|
| `enabled` | `false` | Master switch for the embedded authorization server. |
| `issuer` | `null` | Issuer (`iss`) advertised in metadata and stamped on tokens. Null derives it from the request. |
| `access_token_ttl` | `3600` | Access-token lifetime (seconds). |
| `auth_code_ttl` | `600` | Authorization-code lifetime (seconds). |
| `refresh_token_ttl` | `2592000` | Refresh-token lifetime (seconds). |
| `consent_path` | `/pimcore-studio/oauth/consent` | Studio UI route the authorize endpoint redirects to for login/consent. |
| `allow_localhost_loopback_redirect` | `true` | Also accept `http://localhost:{port}` loopback redirect URIs. Set `false` for RFC 8252-strict (IP literals only). |
| `cors_allowed_origins` | `[]` | Browser origins allowed to call the OAuth endpoints cross-origin. Empty = any origin (wildcard); credentials are never sent. |
| `keys.private_key` | `null` | JWT signing private key (path or contents). |
| `keys.public_key` | `null` | JWT signing public key (path or contents). |
| `keys.passphrase` | `null` | Passphrase for the private key, if any. |
| `keys.encryption_key` | `null` | Encryption key for authorization codes and refresh tokens. |
| `clients` | `[]` | Pre-registered public clients, keyed by `client_id`; each has `name` + `redirect_uris` (see above). |
| `dynamic_client_registration.enabled` | `false` | Expose `POST /pimcore-oauth/register` and advertise it. |
| `client_id_metadata_documents.enabled` | `false` | Resolve URL-form `client_id`s and advertise support. |
| `client_id_metadata_documents.allowed_hosts` | `[]` | If non-empty, a `client_id` URL must be on one of these hosts. |
| `client_id_metadata_documents.allow_insecure` | `false` | Dev only: permit http/loopback `client_id` URLs. |
| `client_id_metadata_documents.cache_ttl` | `300` | Seconds to cache a fetched client metadata document. |
| `resources` | `[]` | Protected resources / token audiences (see above). |

## Security considerations

- **Prefer pre-registered clients when you know your clients up front.** They need no publicly writable
registration endpoint; keep Dynamic Client Registration off unless anonymous clients must self-register.
- **Dynamic Client Registration is open registration.** Enable it only when you intend anonymous clients to
self-register, and consider the network exposure of `/pimcore-oauth/register`.
- **`allow_insecure` and loopback allowances are development conveniences.** Never enable `allow_insecure` in
production; disable `allow_localhost_loopback_redirect` if your clients use IP-literal loopback redirects.

## Related

- [MCP Server Infrastructure](../04_Development_Details/08_MCP_Server.md) — the `pimcore_mcp` firewall,
authenticator chain, and static-token authentication.
- [Installation and Configuration](./README.md) — bundle install and firewall setup.
7 changes: 7 additions & 0 deletions doc/02_Installation_and_Configuration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ security:
> Symfony evaluates firewalls in order, so placing it first ensures `/pimcore-mcp/` requests are matched
> by the correct firewall.

**Optional: OAuth 2.1 authorization server**

The bundle can also act as an embedded OAuth 2.1 authorization server, so standards-based clients can
authenticate with standards-based bearer tokens instead of static credentials. It is opt-in and its endpoints
live at the web root (outside the firewalls above), so it needs its own `access_control` rules. See the
[OAuth 2.1 Authorization Server](./06_OAuth_Server.md) page for setup and configuration.

4) Make sure the bundle is enabled in the `config/bundles.php` file. The following lines should be added:

```php
Expand Down
Loading
Loading