Skip to content

Commit 04a6e85

Browse files
fashxpkingjia90martineiberbluvulture
authored
Added MCP basis functionality for other bundles implementing MCP servers (#1682)
* Added MCP basis functionality for other bundles implementing MCP servers * Apply php-cs-fixer changes * [Task]: Update PHP version requirement to include 8.5.0 (#1681) * added McpToolInterface * Apply php-cs-fixer changes * added missing composer requirements * removed McpToolInterface * Add TokenStorage mock --------- Co-authored-by: fashxp <8792145+fashxp@users.noreply.github.com> Co-authored-by: JiaJia Ji <kingjia90@gmail.com> Co-authored-by: Martin Eiber <martin.eiber@pimcore.com> Co-authored-by: nebojsa.ilic <nebojsa.ilic@pimcore.com>
1 parent c9cd5f8 commit 04a6e85

11 files changed

Lines changed: 594 additions & 9 deletions

File tree

composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
"phpdocumentor/reflection-docblock": "^5.6",
3232
"phpoffice/phpspreadsheet": "^3.3|^4.0|^5.0",
3333
"ext-intl": "*",
34-
"ext-filter": "*"
34+
"ext-filter": "*",
35+
"php-http/discovery": "^1.20",
36+
"psr/http-factory": "^1.0",
37+
"symfony/psr-http-message-bridge": "^6.3 || ^7.0"
3538
},
3639
"require-dev": {
3740
"roave/security-advisories": "dev-latest",

config/mcp.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
services:
2+
_defaults:
3+
autowire: true
4+
autoconfigure: true
5+
public: false
6+
7+
# PSR-17 Factory (implements ResponseFactoryInterface + StreamFactoryInterface)
8+
pimcore_studio_backend.mcp.psr17_factory:
9+
class: Http\Discovery\Psr17Factory
10+
autowire: false
11+
autoconfigure: false
12+
13+
Psr\Http\Message\ResponseFactoryInterface:
14+
alias: pimcore_studio_backend.mcp.psr17_factory
15+
16+
Psr\Http\Message\StreamFactoryInterface:
17+
alias: pimcore_studio_backend.mcp.psr17_factory
18+
19+
# PSR-7 / Symfony HTTP bridge factories
20+
Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory:
21+
autoconfigure: false
22+
23+
Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory:
24+
autoconfigure: false
25+
26+
Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface:
27+
alias: Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory
28+
29+
Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface:
30+
alias: Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory

config/security.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ services:
4040
#Authenticators
4141
Pimcore\Bundle\StudioBackendBundle\Security\Authenticator\AdminTokenAuthenticator: ~
4242

43+
Pimcore\Bundle\StudioBackendBundle\Security\Authenticator\Mcp\SessionBridgeAuthenticator: ~
44+
45+
Pimcore\Bundle\StudioBackendBundle\Security\Authenticator\Mcp\PatAuthenticator:
46+
arguments:
47+
$tokenMap: '%pimcore_studio_backend.mcp.token_map%'
48+
4349
Pimcore\Bundle\StudioBackendBundle\Security\Service\SecurityServiceInterface:
4450
class: Pimcore\Bundle\StudioBackendBundle\Security\Service\SecurityService
4551

doc/00_Installation.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,40 @@ composer require pimcore/studio-backend-bundle
1818

1919
3) Enable Firewall settings
2020

21-
To enable the firewall settings in your project, add the following configuration to your `config/packages/security.yaml` file:
22-
Keep in mind that the prefix part pimcore-studio/api can be changed to any other value in the config.
23-
You need to adapt your access_control settings accordingly.
21+
To enable the firewall settings in your project, add the following configuration to your
22+
`config/packages/security.yaml` file. Keep in mind that the prefix part `pimcore-studio/api` can be changed
23+
to any other value in the config. You need to adapt your `access_control` settings accordingly.
24+
25+
```yaml
26+
security:
27+
firewalls:
28+
pimcore_studio: '%pimcore_studio_backend.firewall_settings%'
29+
access_control:
30+
- { path: ^/pimcore-studio/api/(docs|docs/json|translations|user/reset-password)$, roles: PUBLIC_ACCESS }
31+
- { path: ^/pimcore-studio/api, roles: ROLE_PIMCORE_USER }
32+
```
33+
34+
**Optional: MCP firewall**
35+
36+
If you use bundles that provide MCP (Model Context Protocol) servers (e.g. Data Importer), add the
37+
`pimcore_mcp` firewall as well. This enables authentication for all `/pimcore-mcp/` routes — see the
38+
[MCP Server documentation](./08_MCP_Server.md) for details on authentication and token configuration.
39+
2440
```yaml
2541
security:
26-
firewalls:
42+
firewalls:
43+
pimcore_mcp: '%pimcore_studio_backend.mcp_firewall_settings%'
2744
pimcore_studio: '%pimcore_studio_backend.firewall_settings%'
2845
access_control:
2946
- { path: ^/pimcore-studio/api/(docs|docs/json|translations|user/reset-password)$, roles: PUBLIC_ACCESS }
3047
- { path: ^/pimcore-studio/api, roles: ROLE_PIMCORE_USER }
48+
- { path: ^/pimcore-mcp/, roles: ROLE_PIMCORE_USER }
3149
```
3250

51+
> **Note:** The `pimcore_mcp` firewall must be listed **before** `pimcore_studio` in the firewalls section.
52+
> Symfony evaluates firewalls in order, so placing it first ensures `/pimcore-mcp/` requests are matched
53+
> by the correct firewall.
54+
3355
4) Make sure the bundle is enabled in the `config/bundles.php` file. The following lines should be added:
3456

3557
```php

doc/08_MCP_Server.md

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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.

src/DependencyInjection/Configuration.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ public function getConfigTreeBuilder(): TreeBuilder
8585
$this->addDefaultFromEmail($rootNode);
8686
$this->addGdprDataExtractorNode($rootNode);
8787
$this->addAdminSettingsNode($rootNode);
88+
$this->addMcpNode($rootNode);
8889
$rootNode->append($this->addTwigSandboxNode());
8990

9091
ConfigurationHelper::addConfigLocationWithWriteTargetNodes(
@@ -661,6 +662,33 @@ private function addTwigSandboxNode(): ArrayNodeDefinition
661662
return $node;
662663
}
663664

665+
private function addMcpNode(ArrayNodeDefinition $node): void
666+
{
667+
$node->children()
668+
->arrayNode('mcp')
669+
->addDefaultsIfNotSet()
670+
->info('MCP (Model Context Protocol) server configuration (experimental)')
671+
->children()
672+
->arrayNode('authentication')
673+
->addDefaultsIfNotSet()
674+
->children()
675+
->arrayNode('tokens')
676+
->info(
677+
'Map of Pimcore username to bearer token list. '
678+
. 'Tokens can reference env vars: \'%%env(MY_TOKEN)%%\'.'
679+
)
680+
->useAttributeAsKey('username')
681+
->arrayPrototype()
682+
->scalarPrototype()->end()
683+
->end()
684+
->end()
685+
->end()
686+
->end()
687+
->end()
688+
->end()
689+
->end();
690+
}
691+
664692
private function addDefaultFromEmail(ArrayNodeDefinition $node): void
665693
{
666694
$node->children()

0 commit comments

Comments
 (0)