diff --git a/composer.json b/composer.json index 35af0b3ec..a6b357a8f 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/config/mcp.yaml b/config/mcp.yaml index 0d39d3e03..14051f270 100644 --- a/config/mcp.yaml +++ b/config/mcp.yaml @@ -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 diff --git a/config/oauth.yaml b/config/oauth.yaml index 247232a98..dd7366f64 100644 --- a/config/oauth.yaml +++ b/config/oauth.yaml @@ -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. @@ -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: diff --git a/config/pimcore/routing.yaml b/config/pimcore/routing.yaml index 07009c80b..7f5be4102 100644 --- a/config/pimcore/routing.yaml +++ b/config/pimcore/routing.yaml @@ -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-]+' diff --git a/doc/02_Installation_and_Configuration/06_OAuth_Server.md b/doc/02_Installation_and_Configuration/06_OAuth_Server.md new file mode 100644 index 000000000..9ca2e3c77 --- /dev/null +++ b/doc/02_Installation_and_Configuration/06_OAuth_Server.md @@ -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 `. 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. diff --git a/doc/02_Installation_and_Configuration/README.md b/doc/02_Installation_and_Configuration/README.md index 1c882a201..07a5e06dd 100644 --- a/doc/02_Installation_and_Configuration/README.md +++ b/doc/02_Installation_and_Configuration/README.md @@ -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 diff --git a/doc/03_Extending/15_Providing_MCP_Tools.md b/doc/03_Extending/15_Providing_MCP_Tools.md new file mode 100644 index 000000000..55692b6ad --- /dev/null +++ b/doc/03_Extending/15_Providing_MCP_Tools.md @@ -0,0 +1,108 @@ +--- +title: Providing MCP Tools +description: Contribute MCP tools from your bundle as SDK-native #[McpTool] services; tag them to make them assignable to runtime-configured MCP servers. +--- + +# Providing MCP Tools + +An **MCP tool** is a single capability (read a data object, run a search, …) that a bundle contributes to the +Studio Backend. A tool is an **SDK-native `#[McpTool]` service** — the same shape the `mcp/sdk` package and the +Pimcore Agent bundle use — that you opt in with a tag. Administrators then assign it to a +[runtime-configured MCP server](../04_Development_Details/09_MCP_Server_Management.md). + +This is the tool-author counterpart to the lower-level "implement a whole MCP server in a bundle" recipe in +[MCP Server Infrastructure](../04_Development_Details/08_MCP_Server.md): you provide *tools*, and which servers +expose them and who may use them is configured at runtime. + +> **Experimental.** The feature is under active development and may change between minor versions. + +## The contract + +A tool is a plain service with a public method carrying the `Mcp\Capability\Attribute\McpTool` attribute; the +method returns an `Mcp\Schema\Result\CallToolResult`. There is **no bundle-specific interface** — you use the SDK +types directly. Parameters are described with `Mcp\Capability\Attribute\Schema`, and the input JSON Schema is +inferred from them. + +```php +namespace App\Mcp\Tool; + +use Mcp\Capability\Attribute\McpTool; +use Mcp\Capability\Attribute\Schema; +use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\ToolAnnotations; +use Pimcore\Model\DataObject; + +final class GetObjectNameTool +{ + #[McpTool( + name: 'get_object_name', + title: 'Get Object Name', + description: 'Returns the key of a data object by id.', + annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true), + )] + public function execute( + #[Schema(type: 'integer', description: 'The data object id.', minimum: 1)] + int $id, + ): CallToolResult { + $object = DataObject::getById($id); + if ($object === null) { + return new CallToolResult([new TextContent('No object with that id.')], isError: true); + } + + return new CallToolResult([new TextContent($object->getKey())]); + } +} +``` + +A service may carry more than one `#[McpTool]` method; each becomes a separate tool. The `name` defaults to the +method name and the `description` to the DocBlock summary when omitted. + +## Registering the tool + +Tools are collected explicitly by tag — so a bundle chooses which of its `#[McpTool]` services are exposed as +assignable studio tools: + +```yaml +# config/services.yaml +services: + App\Mcp\Tool\GetObjectNameTool: + autowire: true + tags: ['pimcore.studio_backend.mcp_tool'] +``` + +A compiler pass reflects each tagged service's `#[McpTool]` methods into the tool registry and builds a service +locator the MCP server uses to resolve the backing service at call time. Requirements enforced at build time: + +- a tagged service **must** expose at least one `#[McpTool]` method (otherwise the container fails to compile); +- tool `name`s must be **unique** across all tagged services. + +The tool then appears in the catalogue (`GET /pimcore-studio/api/mcp/tools`) and becomes assignable to a server. + +## Annotations and the required scope + +`ToolAnnotations` mirrors the MCP annotation set and determines the tool's **required OAuth scope**: + +| Annotation | Meaning | +|------------|---------| +| `readOnlyHint` | The tool does not modify state. **`true` ⇒ scope `mcp:read`; otherwise `mcp:write`.** | +| `destructiveHint` | The tool may perform destructive updates (hint). | +| `idempotentHint` | Repeated identical calls have no additional effect (hint). | +| `openWorldHint` | The tool interacts with the outside world (hint). | + +The fail-safe default (no `readOnlyHint`, or `false`) is treated as a write. A server's advertised scopes are the +union of its tools' required scopes. + +## Results and errors + +Return a `CallToolResult` built from `TextContent` (or other `Mcp\Schema\Content\*` types). For a tool-level +failure the caller should see, return a result with `isError: true` and a message you are happy to expose, or +throw `Mcp\Exception\ToolCallException` (the SDK converts it to an error result). **Uncaught exceptions are +sanitized by a shared terminal error boundary** that forwards no internal exception message, so never rely on an +exception message reaching the caller. + +## Related + +- [MCP Server Management](../04_Development_Details/09_MCP_Server_Management.md) — assigning tools to servers, + the permission, and the sharing model. +- [MCP Server Infrastructure](../04_Development_Details/08_MCP_Server.md) — the firewall and authentication. diff --git a/doc/03_Extending/README.md b/doc/03_Extending/README.md index c3fbc1b5b..42286baf9 100644 --- a/doc/03_Extending/README.md +++ b/doc/03_Extending/README.md @@ -50,3 +50,4 @@ pimcore_studio_backend: - [Custom Widget Types](./11_Perspectives/01_Extending_Widgets.md) - [GDPR Data Providers](./12_Extending_GDPR_Data_Providers.md) - [Ownership Management Providers](./13_Extending_Ownership_Management.md) +- [Providing MCP Tools](./15_Providing_MCP_Tools.md) diff --git a/doc/04_Development_Details/07_OAuth_Protected_Applications.md b/doc/04_Development_Details/07_OAuth_Protected_Applications.md new file mode 100644 index 000000000..ecf47f52d --- /dev/null +++ b/doc/04_Development_Details/07_OAuth_Protected_Applications.md @@ -0,0 +1,285 @@ +--- +title: OAuth-Protected Applications +description: Build a bundle's endpoints into an OAuth resource server using the embedded authorization server. +--- + +# OAuth-Protected Applications (Experimental) + +The [embedded OAuth 2.1 authorization server](../02_Installation_and_Configuration/06_OAuth_Server.md) is a +platform capability, not a feature of any one bundle. It issues tokens; it does not care what those tokens +are eventually presented to. + +Any bundle can accept those tokens and become an **OAuth-protected application**. This page describes the +contracts to build against and the shape such an application takes, then walks through adding one. + +> **Experimental.** Contracts on this page are public API, but the surrounding feature is under active +> development. Pin the bundle version. + +## Two roles + +OAuth splits into two roles, and this bundle fills only the first by default: + +| Role | Responsibility | Who | +|------|----------------|-----| +| Authorization server | Authenticates the human, runs consent, issues and revokes tokens | This bundle, once enabled. Exactly one per installation. | +| Resource server | Accepts a token on its own endpoints, resolves it to a Pimcore user, decides what that user may do | Any bundle. Several per installation. | + +A resource server never issues, refreshes or revokes tokens, and never needs the signing keys. It validates +what it is handed and applies its own authorization rules. + +## Applications today + +| Application | Endpoint | Authenticates in | Authorization model | +|-------------|----------|------------------|---------------------| +| Pimcore MCP servers | `/pimcore-mcp/…` | a Symfony firewall | The resolved user's own Pimcore permissions, plus per-server sharing | +| Data Hub Simple REST | `/pimcore-datahub-webservices/simplerest…` (REST and MCP) | a request-argument resolver and a controller base class | Per-configuration allow-list of users and roles; data exposure stays driven by the Data Hub configuration | + +They differ deliberately, and in more than one dimension. Authentication is shared; **authorization is each +application's own business**, and so is *where* the credential is checked. The platform tells you *who* is +calling, never *what they may do*. + +Data Hub Simple REST is worth studying as an example of one resource covering two surfaces: its REST +endpoints and its MCP endpoint share a single protected resource, so a user consents once and the +resulting token works across both. Each surface checks the token where it already authenticated, +which is why the same application appears twice in the "authenticates in" column. + +## Public contracts + +| Contract | Purpose | +|----------|---------| +| `OAuth\Contract\ScopeProviderInterface` | Contribute your own scope identifiers to the server's catalogue | +| `OAuth\Contract\ScopeRegistryInterface` | Read the catalogue | +| `OAuth\Contract\TokenValidatorInterface` | Validate a raw bearer token and resolve it to effective access | +| `OAuth\Dto\ResolvedAccess` | Result of validation: the Pimcore user, granted scopes, audience, client id | +| `OAuth\Contract\ResourceRegistryInterface` | Register endpoints as protected resources, making their RFC 9728 metadata resolvable | +| `OAuth\Dto\ProtectedResource` | One protected resource: canonical URI, supported scopes, authorization servers | +| `OAuth\Dto\ProtectedResourceMetadata` | The metadata document served for a resource | + +Everything else under `OAuth\` is `@internal` and may change without notice. In particular, do not depend on +`OAuth\Util\CanonicalUri`: the registry canonicalises on both registration and lookup, so any equivalent form +of a URI works. + +## Anatomy of an application + +Five parts, in the order a request meets them. + +**1. A place to authenticate.** Two shapes are in use, and the right one depends on what your bundle already +does: + +- **A Symfony firewall** over your own routes, stateless, using the `pimcore_studio_backend` user provider. + Right when your endpoints have no authentication of their own yet, or already use the security component. + This is what the MCP servers do. Declare your own firewall over your own prefix rather than putting your + endpoints under another bundle's URL prefix to borrow its firewall. +- **Your existing request pipeline**, if the bundle already authenticates somewhere else. Data Hub Simple + REST checks credentials in a `ValueResolverInterface` and has no `security.yaml` at all; bolting a firewall + on would have duplicated that and forced every installation to edit its security configuration. It added a + branch where it already authenticated instead. + +**2. Token validation.** Claim JWT-shaped bearer tokens, call `TokenValidatorInterface::validate()`, and +resolve the user. Whatever shape you chose, leave every other credential your bundle supports working: shape +is not proof, so a credential that looks like a token but does not resolve should fall through to your +existing check rather than being rejected. + +**3. Resource registration.** One `ProtectedResource` per endpoint that acts as a token audience. This does +two things: it makes `/.well-known/oauth-protected-resource/` resolvable, which is how a client +discovers the authorization server, and it is what the authorization endpoint validates a requested +`resource` against. One endpoint means one resource, even when it serves many logical things behind it. + +Register on every request that might consult the registry. That includes your own endpoint, its metadata +document, and the OAuth endpoints, since the authorization request is validated there. Deriving the URI from +the configured issuer rather than the request host keeps registration idempotent. + +**4. A 401 challenge** carrying `WWW-Authenticate: Bearer resource_metadata="…"`. Without this parameter a +standards-based client cannot begin discovery, so the whole flow never starts. + +**5. Your authorization rules**, applied after authentication, wherever your bundle resolves a request to +the thing being accessed. + +## Blueprint: adding an application + +Data Hub Simple REST is the worked example. Where it and the MCP servers differ, both are shown. + +### Step 1: Choose where to authenticate + +**If your bundle has no authentication of its own**, declare a firewall. + +Expose the settings as a parameter, the way other bundles do, so integrators add one line to +`security.yaml`: + +```php +$container->setParameter('my_bundle.firewall_settings', [ + 'pattern' => '^/my-bundle-prefix', + 'provider' => 'pimcore_studio_backend', + 'user_checker' => 'Pimcore\Security\User\UserChecker', + 'stateless' => true, + 'custom_authenticators' => [MyOAuthAuthenticator::class], + 'entry_point' => MyAuthenticationEntryPoint::class, +]); +``` + +Integrators then add it **before** any catch-all firewall, because Symfony picks the first matching one: + +```yaml +security: + firewalls: + my_bundle: '%my_bundle.firewall_settings%' +``` + +Two decisions worth making consciously: + +- **Permit anonymous requests** if your bundle already authenticates some callers itself (a static API key, + for example). Then adding the firewall does not change behaviour for existing integrations, and OAuth is + purely additive. The cost is that your firewall's `entry_point` never fires, so you emit the 401 challenge + from your own error handling instead (step 4). +- **Supply an explicit rate limiter.** Symfony's default builds a per-IP tier that every client on an address + shares, so guesses against one credential can push an unrelated valid credential into a `429`. + +**If your bundle already authenticates elsewhere**, skip the firewall entirely and add a branch there. Data +Hub Simple REST does this in `McpAuthContextResolver`, so integrators need no `security.yaml` change at all +and existing traffic is untouched. The rest of the steps are the same; only step 2 changes shape. + +### Step 2: Validate the token + +**In a firewall**, that means an authenticator. + +```php +final class MyOAuthAuthenticator extends AbstractAuthenticator +{ + public function __construct( + private readonly bool $oauthEnabled, + private readonly TokenValidatorInterface $tokenValidator, + ) { + } + + public function supports(Request $request): bool + { + // Claim only JWT-shaped bearers, so other credentials fall through. + return $this->oauthEnabled && $this->isJwtBearer($request); + } + + public function authenticate(Request $request): Passport + { + $access = $this->tokenValidator->validate( + $this->bearerToken($request), + $this->resourceUriFor($request), + ); + + if (!$access?->user instanceof User) { + throw new AuthenticationException('Invalid or expired access token.'); + } + + return new SelfValidatingPassport( + new UserBadge($access->user->getUsername(), static fn () => new SecurityUser($access->user)), + ); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $e): ?Response + { + // Return null so other authenticators, or your own in-controller check, still run. + return null; + } +} +``` + +**In an existing pipeline**, it is the same three calls without the Symfony scaffolding: recognise the +credential, call `validate()`, resolve the user, and on failure continue to whatever check you had before. + +Either way, gate it on `%pimcore_studio_backend.oauth.enabled%` so the code is inert when the authorization +server is switched off. + +### Step 3: Register protected resources + +Inject `ResourceRegistryInterface` and register one resource per endpoint that acts as an audience. The +canonical URI depends on the incoming scheme and host, so registration happens per request rather than at +compile time: + +```php +$this->resourceRegistry->register( + new ProtectedResource( + $request->getSchemeAndHttpHost() . '/my-bundle-prefix/endpoint', + ['mcp:read'], + [$issuer], + ) +); +``` + +Gate that on the paths that actually consult the registry, your endpoint and the well-known prefix. Running +it on every request lets a varied `Host` header grow the registry unboundedly on a long-running worker. + +### Step 4: Emit the challenge + +An unauthenticated request must answer `401` with a `resource_metadata` pointer: + +``` +WWW-Authenticate: Bearer resource_metadata="https://host/.well-known/oauth-protected-resource/my-bundle-prefix/" +``` + +`error="invalid_token"` belongs there only when a token was actually presented and rejected. Omit it when no +credential was sent at all. + +### Step 5: Declare your scopes + +Use your own prefix rather than another application's. Sharing `mcp:read` between two applications makes the +consent screen ambiguous about what is being granted, and prevents a token being narrowed to one of them. + +```php +final class MyScopeProvider implements ScopeProviderInterface +{ + public function scopes(): array + { + return ['mybundle:read']; + } +} +``` + +Tag the service with `ScopeProviderInterface::TAG`. The authorization endpoint then accepts the scope, +dynamic clients may register it, and the server metadata advertises it. Ship only scopes that correspond to +operations you actually have: a scope a user can consent to that grants nothing is worse than no scope. + +### Step 6: Apply your own authorization + +Authentication produced a Pimcore user. What that user may do is yours to decide, at the point where a +request resolves to the thing being accessed. Two rules that matter: + +- **Do not apply user-bound checks to credentials that carry no user.** If your bundle also accepts a static + key, that caller has no user, so a user allow-list must not apply to it. Getting this wrong breaks every + existing integration the moment the firewall is added. +- **Keep admission separate from data authorization.** "May this user connect to this endpoint" and "what may + they see once connected" are different questions with different answers, and conflating them makes both + harder to reason about. + +## Audience binding + +A token is bound to the resource it was requested for. A client names it with the RFC 8707 `resource` +parameter on the authorization request; the server validates it against the registry, rejects an unknown one +with `invalid_request`, and stamps it as the token's `aud`. `TokenValidatorInterface::validate()` then refuses +a token whose audience does not name the resource URI you pass it. + +This is what stops a token obtained for one application being replayed against another. Without it, every +protected resource on the installation accepts every token the server ever issued, which is a real hole once +more than one resource exists. + +Two consequences for an application: + +- **Pass your own resource URI to `validate()`**, and derive it the same way every time. Prefer the + configured issuer over the request's host: the URI has to be byte-identical when the resource is + registered, when a token is requested for it, and when that token is validated, and a Host header behind a + proxy will not be. +- **A token with no audience is accepted.** A client that does not send `resource` gets an unbound token, + which stays valid everywhere. That keeps existing clients working, and it means audience binding protects + clients that opt in rather than being a wall. Do not treat the presence of an audience as guaranteed. + +## What the platform does not do yet + +**Scopes are not enforced.** They are requested, consented to, carried on the token and advertised per +resource, but nothing compares a granted scope against an operation. Treat a scope as a label shown at +consent time, not a guarantee, and enforce it yourself if your operations differ in privilege. + +## Related + +- [OAuth 2.1 Authorization Server](../02_Installation_and_Configuration/06_OAuth_Server.md) - enabling and + configuring the authorization server +- [MCP Server Infrastructure](./08_MCP_Server.md) - the first application, and the other credentials its + firewall accepts +- [MCP Server Management](./09_MCP_Server_Management.md) - configuring MCP servers, independent of how a + caller authenticated diff --git a/doc/04_Development_Details/08_MCP_Server.md b/doc/04_Development_Details/08_MCP_Server.md index 0dc8ac274..566b23135 100644 --- a/doc/04_Development_Details/08_MCP_Server.md +++ b/doc/04_Development_Details/08_MCP_Server.md @@ -36,14 +36,16 @@ instead carry a Pimcore Studio session cookie. ### Authenticator chain -The firewall tries these authenticators in order. The first two return `null` on failure so the next one can try; -`PatAuthenticator` is last and therefore owns the terminal response, answering `401` (or `429` when throttled): +The firewall tries these authenticators in order. All but the last return `null` on failure so the next one can +try; `PatAuthenticator` is last and therefore owns the terminal response, answering `401` (or `429` when +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 | `PatAuthenticator` | `Authorization: Bearer ` | External: MCP clients (Claude Desktop, Cursor, etc.) | +| 3 | `OAuthAccessTokenAuthenticator` | `Authorization: Bearer ` | External: clients using the [embedded OAuth 2.1 server](../02_Installation_and_Configuration/06_OAuth_Server.md); inert unless OAuth is enabled | +| 4 | `PatAuthenticator` | `Authorization: Bearer ` | External: MCP clients using static Personal Access Tokens (Claude Desktop, Cursor, etc.) | ### `McpAccessTokenAuthenticator` (primary internal) @@ -56,6 +58,22 @@ The studio-backend bundle owns both validation (`McpAccessTokenAuthenticator`) a primitives. Consuming bundles (e.g. `pimcore-agent-bundle`) call those primitives to mint tokens for their own MCP servers - see [Minting MCP access tokens](#minting-mcp-access-tokens). +### `OAuthAccessTokenAuthenticator` (OAuth 2.1 bearer) + +Authenticates a JWT access token issued by the [embedded OAuth 2.1 authorization +server](../02_Installation_and_Configuration/06_OAuth_Server.md) (`Authorization: Bearer `). 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`, +so `PatAuthenticator` still runs, hence it must precede it in the chain. + +MCP is one application of the OAuth server, not its purpose. The same contracts protect Data Hub 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](./07_OAuth_Protected_Applications.md) for the contracts and the blueprint, and +[OAuth 2.1 Authorization Server](../02_Installation_and_Configuration/06_OAuth_Server.md) for enabling the +server. + ### `PatAuthenticator` (external clients) External MCP clients authenticate with static Personal Access Tokens configured in YAML. It deliberately declines any diff --git a/doc/04_Development_Details/09_MCP_Server_Management.md b/doc/04_Development_Details/09_MCP_Server_Management.md new file mode 100644 index 000000000..169db29d0 --- /dev/null +++ b/doc/04_Development_Details/09_MCP_Server_Management.md @@ -0,0 +1,116 @@ +--- +title: MCP Server Management +description: Configure MCP servers at runtime — assign tools, control access with per-user and per-role capabilities, and manage them in Pimcore Studio. +--- + +# MCP Server Management (Experimental) + +An **MCP server** groups [MCP tools](../03_Extending/15_Providing_MCP_Tools.md) under a URL and controls who +may use them. Unlike the code-defined servers in +[MCP Server Infrastructure](./08_MCP_Server.md), these are **managed at runtime** — created and edited without a +deployment — through the Studio API and the Studio UI, and persisted via Pimcore's location-aware configuration. + +Each server is served at `/pimcore-mcp/studio/{urlSlug}` on the `pimcore_mcp` firewall; clients authenticate with +a token from the [embedded OAuth 2.1 server](../02_Installation_and_Configuration/06_OAuth_Server.md) (or the +other authenticators in the chain). + +> **Experimental.** Configuration keys, API, and UI may change between minor versions. + +This is a configuration topic, independent of authentication. Which servers exist, which tools they expose, +and who may use them are decided the same way whichever credential admitted the caller: an OAuth bearer, an +MCP access token, a Personal Access Token, or a Pimcore Studio session cookie. See +[MCP Server Infrastructure](./08_MCP_Server.md) for the credentials themselves. + +## The `mcp_servers` permission + +A single Pimcore user permission, `mcp_servers`, gates **creating** servers and **browsing the tool catalogue**. +Grant it to users or roles in the permission editor (admins bypass all checks). It is installed with the bundle; +existing installations receive it through a migration. + +Viewing and editing *existing* servers is **not** governed by this permission but per-server — see +[Access and sharing](#access-and-sharing). + +## Enabling runtime editing + +Servers are stored under the `studio_mcp_servers` configuration location. The default write target is +`symfony-config` (YAML), which is **read-only outside debug mode**. To create and edit servers at runtime, point +the write target at the settings store (database): + +```yaml +pimcore_studio_backend: + config_location: + studio_mcp_servers: + write_target: + type: settings-store +``` + +When the target is read-only, the API reports `writeable: false` on every server and the UI renders read-only. +For details on storage locations, see +[Configuration Storage Locations](https://pimcore.com/docs/platform/Pimcore/Deployment/Configuration_Environments/#configuration-storage-locations--fallbacks). + +## The Studio API + +All endpoints live under the Studio API prefix (`/pimcore-studio/api`) and are documented in the +[OpenAPI docs](../02_Installation_and_Configuration/README.md#openapi-documentation) (tag **MCP**). + +| Method · Path | Gate | +|---------------|------| +| `GET /mcp/servers` | none — returns only servers the caller can **read** | +| `GET /mcp/servers/{id}` | **Config Read** on the server | +| `POST /mcp/servers` | `mcp_servers` | +| `PUT /mcp/servers/{id}` | **Config Edit** on the server | +| `DELETE /mcp/servers/{id}` | **Config Edit** on the server | +| `GET /mcp/tools` | `mcp_servers` | + +A server's advertised `scopes` are **derived** from its tools' required scopes and cannot be set directly; the +`urlSlug` is fixed on create and locked on update. + +## Access and sharing + +Access is **deny-by-default** with three **independent** capabilities per server: + +- **Config Read** — see the server in the list and view its configuration (read-only). +- **Config Edit** — change the configuration, change its sharing, and delete it. +- **MCP Server Access** — connect a client to the *running* server over its URL. + +A server carries an **owner** (its creator), a **public** flag (`shareGlobal`), and per-**user** and per-**role** +grants. Each grant is `{ name, canAccess, canEdit }`; being listed at all conveys **Config Read**. Users and +roles are matched by **name**, and a user's own grant is unioned with their role grants (most-permissive wins). +The capabilities resolve independently: + +- **Config Read** — admin, the owner, a public server, or any listed user/role. +- **Config Edit** — admin, the owner, or a grant with `canEdit`. +- **MCP Server Access** — a public server, or a grant with `canAccess`. **Neither admins nor the owner get + Access implicitly**; it must be granted explicitly (they can add themselves to the user list with Access). This + keeps a server's *runtime* deliberately closed — managing every server does not mean being able to connect to + one. + +So the owner and admins are **symmetric**: both always have **Config Read + Config Edit**, and both need an +explicit grant for **MCP Server Access**. A **public** server (`shareGlobal: true`) grants **Config Read + MCP +Server Access** to every authenticated user (not Edit); it is then editable only by admins and the owner. + +**MCP Server Access** — not read — gates connecting to the running server at `/pimcore-mcp/studio/{urlSlug}`. Each +server response includes `currentUserPermissions` (`{ canView, canAccess, canEdit }`) — the caller's resolved +capabilities — so clients need not re-derive them. + +## Managing servers in Pimcore Studio + +The Studio UI presents server management as a **master/detail** screen: + +- A **left rail** lists the servers the current user can read, by name, with a **New server** action (shown only + to holders of `mcp_servers`). +- Selecting a server opens its **configuration mask**: identity (name, locked url-slug, description, enabled), + the **tool picker** (from the catalogue, each showing its read/write scope), the **sharing panel**, and the + derived scopes plus the copyable server URL. +- The **sharing panel** is a grid: pick a user or role, then set **Config Edit** and **MCP Server Access** (being + listed already conveys Config Read). The owner and admins are **not** listed rows — their Config Read + Config + Edit is implicit; to gain runtime access they add themselves as a user with **MCP Server Access**. +- When the current user has **read but not edit** (`currentUserPermissions.canEdit === false`), the mask opens + **read-only**: fields are disabled and Save/Delete are hidden, but copying the URL stays available. + +## Related + +- [Providing MCP Tools](../03_Extending/15_Providing_MCP_Tools.md) — contributing the tools a server exposes. +- [MCP Server Infrastructure](./08_MCP_Server.md) — the `pimcore_mcp` firewall and authentication. +- [OAuth 2.1 Authorization Server](../02_Installation_and_Configuration/06_OAuth_Server.md) — how clients obtain + a token. diff --git a/doc/04_Development_Details/README.md b/doc/04_Development_Details/README.md index e6ed9db45..19555951e 100644 --- a/doc/04_Development_Details/README.md +++ b/doc/04_Development_Details/README.md @@ -10,4 +10,6 @@ conventions, testing approaches, and infrastructure services. - [Dot Notation for Field Definitions](./01_Dot_Notation_for_Field_Definitions.md) - How nested field paths are represented in API requests and responses - [API Testing](./02_API_Testing.md) - Running and writing tests for Studio Backend endpoints +- [OAuth-Protected Applications](./07_OAuth_Protected_Applications.md) - Make a bundle's endpoints an OAuth resource server, with the blueprint for adding one (experimental) - [MCP Server Infrastructure](./08_MCP_Server.md) - Shared infrastructure for Model Context Protocol servers (experimental) +- [MCP Server Management](./09_MCP_Server_Management.md) - Configure MCP servers at runtime: tools, read/write sharing, and the Studio management UI (experimental) diff --git a/src/DependencyInjection/CompilerPass/McpToolPass.php b/src/DependencyInjection/CompilerPass/McpToolPass.php new file mode 100644 index 000000000..aef4bb9e7 --- /dev/null +++ b/src/DependencyInjection/CompilerPass/McpToolPass.php @@ -0,0 +1,126 @@ +hasDefinition(McpToolRegistry::class)) { + return; + } + + $metadata = []; + $locatorRefs = []; + + foreach ($container->findTaggedServiceIds(McpToolRegistry::TAG) as $serviceId => $tags) { + $tools = $this->extractToolMetadata($container, $serviceId); + if ($tools === []) { + throw new InvalidArgumentException(sprintf( + 'Service "%s" is tagged "%s" but exposes no #[McpTool] method.', + $serviceId, + McpToolRegistry::TAG + )); + } + + $locatorRefs[$serviceId] = new Reference($serviceId); + + foreach ($tools as $tool) { + if (isset($metadata[$tool['name']])) { + throw new InvalidArgumentException(sprintf( + 'Duplicate MCP tool name "%s": already provided by "%s", conflict with "%s".', + $tool['name'], + $metadata[$tool['name']]['class'], + $serviceId + )); + } + + $metadata[$tool['name']] = [ + 'class' => $serviceId, + 'method' => $tool['method'], + 'title' => $tool['title'], + 'description' => $tool['description'], + 'annotations' => $tool['annotations'], + 'outputSchema' => $tool['outputSchema'], + ]; + } + } + + $locator = new Definition(ServiceLocator::class, [$locatorRefs]); + $locator->addTag('container.service_locator'); + $container->setDefinition(self::LOCATOR_ID, $locator); + + $registry = $container->getDefinition(McpToolRegistry::class); + $registry->setArgument('$toolMetadata', $metadata); + $registry->setArgument('$toolLocator', new Reference(self::LOCATOR_ID)); + } + + /** + * @return list|null, outputSchema: array|null}> + */ + private function extractToolMetadata(ContainerBuilder $container, string $serviceId): array + { + $class = $container->getDefinition($serviceId)->getClass() ?? $serviceId; + + try { + $reflection = new ReflectionClass($class); + } catch (ReflectionException) { + return []; + } + + $tools = []; + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + $attributes = $method->getAttributes(McpTool::class); + if ($attributes === []) { + continue; + } + + $attribute = $attributes[0]->newInstance(); + $tools[] = [ + 'name' => $attribute->name ?? $method->getName(), + 'method' => $method->getName(), + 'title' => $attribute->title, + 'description' => $attribute->description ?? '', + 'annotations' => $attribute->annotations?->jsonSerialize(), + 'outputSchema' => $attribute->outputSchema, + ]; + } + + return $tools; + } +} diff --git a/src/DependencyInjection/CompilerPass/OAuthScopeProviderPass.php b/src/DependencyInjection/CompilerPass/OAuthScopeProviderPass.php new file mode 100644 index 000000000..18af86a0f --- /dev/null +++ b/src/DependencyInjection/CompilerPass/OAuthScopeProviderPass.php @@ -0,0 +1,39 @@ +findTaggedServiceIds(ScopeProviderInterface::TAG)) as $serviceId) { + $this->checkInterface($serviceId, ScopeProviderInterface::class); + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 1673e3866..358afbe5d 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -47,6 +47,8 @@ class Configuration implements ConfigurationInterface public const string TREE_WIDGETS_NODE = WidgetTypes::ELEMENT_TREE->value . '_widgets'; + public const string MCP_SERVERS_NODE = 'studio_mcp_servers'; + private const string WIDGETS_ARRAY_VALUE_ERROR = 'Each widget id value must be a string.'; private const string PERMISSION_ARRAY_VALUE_ERROR = 'Each permission value must be a boolean.'; @@ -88,6 +90,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addGdprDataExtractorNode($rootNode); $this->addAdminSettingsNode($rootNode); $this->addMcpNode($rootNode); + $this->addMcpServersConfigurationNode($rootNode); $this->addOAuthNode($rootNode); $this->addRateLimitingNode($rootNode); $this->addTranslation($rootNode); @@ -102,6 +105,8 @@ public function getConfigTreeBuilder(): TreeBuilder PIMCORE_CONFIGURATION_DIRECTORY . '/' . self::PERSPECTIVES_NODE, self::ADMIN_SETTINGS_NODE => PIMCORE_CONFIGURATION_DIRECTORY . '/' . SettingRepository::SCOPE, + self::MCP_SERVERS_NODE => + PIMCORE_CONFIGURATION_DIRECTORY . '/' . self::MCP_SERVERS_NODE, ], ['read_target'] ); @@ -791,6 +796,92 @@ private function addMcpNode(ArrayNodeDefinition $node): void ->end(); } + /** + * MCP server definitions: named tool groups exposed at /pimcore-mcp/{url_slug}. + * Shipped defaults live here; runtime-managed servers use the write target + * configured under config_location.studio_mcp_servers. + */ + private function addMcpServersConfigurationNode(ArrayNodeDefinition $node): void + { + $node->children() + ->arrayNode(self::MCP_SERVERS_NODE) + ->info('MCP server definitions, keyed by server id.') + ->defaultValue([]) + ->useAttributeAsKey('id') + ->arrayPrototype() + ->children() + ->scalarNode('name') + ->info('Human-facing display name; defaults to the id.') + ->defaultNull() + ->end() + ->scalarNode('description') + ->defaultValue('') + ->end() + ->scalarNode('url_slug') + ->info('URL segment under /pimcore-mcp/; defaults to the id.') + ->defaultNull() + ->end() + ->arrayNode('tools') + ->info('Ids of the assigned MCP tools.') + ->scalarPrototype()->end() + ->defaultValue([]) + ->end() + ->arrayNode('scopes') + ->info('OAuth scopes advertised for this server (e.g. mcp:read, mcp:write).') + ->scalarPrototype()->end() + ->defaultValue([]) + ->end() + ->booleanNode('enabled') + ->defaultTrue() + ->end() + ->arrayNode('access') + ->addDefaultsIfNotSet() + ->info('Who may access the server: owner (username) + public flag + user/role grants.') + ->children() + ->scalarNode('owner') + ->info('Creator username; auto-listed with full capabilities. Names, not ids.') + ->defaultNull() + ->end() + ->booleanNode('share_global') + ->info('Public: any authenticated user may view and use the server (not edit).') + ->defaultFalse() + ->end() + ->append($this->mcpAccessGrantListNode('shared_users', 'Users granted access, by unique username.')) + ->append($this->mcpAccessGrantListNode('shared_roles', 'Roles granted access, by unique role name.')) + ->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + } + + /** + * A list of access grants keyed by unique name — the file-config equivalent + * of the settings-store {name, can_access, can_edit} grid. A bare string is + * accepted as a view-only grant (name only), matching {@see McpServerAccessEntry::fromMixed()}. + */ + private function mcpAccessGrantListNode(string $name, string $info): ArrayNodeDefinition + { + $node = (new TreeBuilder($name))->getRootNode(); + $node + ->info($info) + ->arrayPrototype() + ->beforeNormalization() + ->ifString() + ->then(static fn (string $value): array => ['name' => $value]) + ->end() + ->children() + ->scalarNode('name')->isRequired()->cannotBeEmpty()->end() + ->booleanNode('can_access')->defaultFalse()->end() + ->booleanNode('can_edit')->defaultFalse()->end() + ->end() + ->end() + ->defaultValue([]); + + return $node; + } + private function addOAuthNode(ArrayNodeDefinition $node): void { $node->children() diff --git a/src/DependencyInjection/PimcoreStudioBackendExtension.php b/src/DependencyInjection/PimcoreStudioBackendExtension.php index 6f9290cc3..0f1cbed26 100644 --- a/src/DependencyInjection/PimcoreStudioBackendExtension.php +++ b/src/DependencyInjection/PimcoreStudioBackendExtension.php @@ -29,6 +29,7 @@ use Pimcore\Bundle\StudioBackendBundle\Export\Service\XlsxExportService; use Pimcore\Bundle\StudioBackendBundle\Grid\Column\Collector\DataObject\FieldDefinitionCollector; use Pimcore\Bundle\StudioBackendBundle\Grid\Service\ConfigurationServiceInterface; +use Pimcore\Bundle\StudioBackendBundle\Mcp\Repository\McpServerConfigRepositoryInterface; use Pimcore\Bundle\StudioBackendBundle\Mercure\Service\UrlServiceInterface; use Pimcore\Bundle\StudioBackendBundle\Metadata\Service\DataAdapterServiceInterface as MetadataAdapterServiceInterface; use Pimcore\Bundle\StudioBackendBundle\Note\Service\NoteServiceInterface; @@ -242,7 +243,13 @@ public function load(array $configs, ContainerBuilder $container): void $container->setParameter('pimcore_studio_backend.oauth.resources', $config['oauth']['resources']); $container->getDefinition(ResourceRegistryInterface::class) - ->setArgument('$resources', $config['oauth']['resources']); + ->setArgument('$resources', [ + ...$config['oauth']['resources'], + ...$this->buildMcpServerResources( + $config[Configuration::MCP_SERVERS_NODE], + $config['oauth']['issuer'], + ), + ]); $container->getDefinition(TokenValidatorInterface::class) ->setArgument('$publicKey', $config['oauth']['keys']['public_key']) @@ -311,6 +318,10 @@ public function load(array $configs, ContainerBuilder $container): void '$storageConfig' => $config['config_location'][Configuration::ADMIN_SETTINGS_NODE], ]); + $container->getDefinition(McpServerConfigRepositoryInterface::class) + ->setArgument('$serverConfigurations', $config[Configuration::MCP_SERVERS_NODE]) + ->setArgument('$storageConfig', $config['config_location'][Configuration::MCP_SERVERS_NODE]); + $this->populateTwigSandboxExtension($config, $container); // MCP authentication token map @@ -350,6 +361,11 @@ public function prepend(ContainerBuilder $container): void 'pimcore_studio_backend.oauth.client_metadata' => [ 'adapter' => 'cache.adapter.filesystem', ], + // MCP session store (per-server, keyed by slug); dedicated so + // it never collides with another bundle's MCP sessions. + 'pimcore_studio_backend.mcp.session' => [ + 'adapter' => 'cache.adapter.filesystem', + ], ], ], ]); @@ -447,6 +463,42 @@ public function prepend(ContainerBuilder $container): void $this->prependCustomConfig($container, $containerConfig, Configuration::PERSPECTIVES_NODE); $this->prependCustomConfig($container, $containerConfig, Configuration::TREE_WIDGETS_NODE); $this->prependCustomConfig($container, $containerConfig, Configuration::ADMIN_SETTINGS_NODE); + $this->prependCustomConfig($container, $containerConfig, Configuration::MCP_SERVERS_NODE); + } + + /** + * Advertises each enabled MCP server as an RFC 9728 protected resource, so the + * per-server discovery and 401 challenge resolve. Requires a configured issuer + * to build the absolute resource URL; skipped otherwise (a null issuer derives + * from the request at runtime, which cannot be seeded at container-build time). + * + * @param array> $servers + * + * @return list, authorization_servers: list}> + */ + private function buildMcpServerResources(array $servers, ?string $issuer): array + { + if ($issuer === null) { + return []; + } + + $base = rtrim($issuer, '/'); + $resources = []; + foreach ($servers as $id => $server) { + if (($server['enabled'] ?? true) === false) { + continue; + } + + $slug = $server['url_slug'] ?? $id; + $scopes = $server['scopes'] ?? []; + $resources[] = [ + 'uri' => $base . '/pimcore-mcp/studio/' . $slug, + 'scopes_supported' => $scopes !== [] ? $scopes : ['mcp:read', 'mcp:write'], + 'authorization_servers' => [$issuer], + ]; + } + + return $resources; } /** diff --git a/src/Entity/OAuth/OAuthTokenRecord.php b/src/Entity/OAuth/OAuthTokenRecord.php index af08a0c85..85cef20af 100644 --- a/src/Entity/OAuth/OAuthTokenRecord.php +++ b/src/Entity/OAuth/OAuthTokenRecord.php @@ -53,6 +53,9 @@ class OAuthTokenRecord #[ORM\Column(name: 'user_id', type: 'integer', nullable: true, options: ['unsigned' => true])] private ?int $userId; + #[ORM\Column(name: 'resource', type: 'string', length: 512, nullable: true)] + private ?string $resource = null; + #[ORM\Column(name: 'client_id', type: 'string', length: 255, nullable: true)] private ?string $clientId; @@ -114,4 +117,19 @@ public function getCreatedAt(): int { return (int) $this->createdAt; } + + /** + * The protected resource this token is bound to (RFC 8707), carried here so the + * binding survives the authorization-code and refresh exchanges without having to + * duplicate league's payload construction. + */ + public function getResource(): ?string + { + return $this->resource; + } + + public function setResource(?string $resource): void + { + $this->resource = $resource; + } } diff --git a/src/Installer.php b/src/Installer.php index 5164cf59a..b4277231d 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -43,6 +43,10 @@ final class Installer extends SettingsStoreAwareInstaller UserPermissions::WIDGET_EDITOR->value, ]; + private const array MCP_PERMISSIONS = [ + UserPermissions::MCP_SERVERS->value, + ]; + public function __construct( private readonly Connection $db, BundleInterface $bundle, @@ -542,7 +546,7 @@ private function createMcpAccessTokenTable(Schema $schema): void private function addUserPermission(Schema $schema): void { if ($schema->hasTable(UserPermissions::DEFINITIONS_TABLE->value)) { - foreach (self::PERSPECTIVE_PERMISSIONS as $permission) { + foreach ([...self::PERSPECTIVE_PERMISSIONS, ...self::MCP_PERMISSIONS] as $permission) { $queryBuilder = $this->db->createQueryBuilder(); $queryBuilder ->insert(UserPermissions::DEFINITIONS_TABLE->value) @@ -566,7 +570,7 @@ private function addUserPermission(Schema $schema): void private function removeUserPermission(Schema $schema): void { if ($schema->hasTable(UserPermissions::DEFINITIONS_TABLE->value)) { - foreach (self::PERSPECTIVE_PERMISSIONS as $permission) { + foreach ([...self::PERSPECTIVE_PERMISSIONS, ...self::MCP_PERMISSIONS] as $permission) { $queryBuilder = $this->db->createQueryBuilder(); $queryBuilder ->delete(UserPermissions::DEFINITIONS_TABLE->value) diff --git a/src/Mcp/Attribute/Request/McpServerRequestBody.php b/src/Mcp/Attribute/Request/McpServerRequestBody.php new file mode 100644 index 000000000..0127782e0 --- /dev/null +++ b/src/Mcp/Attribute/Request/McpServerRequestBody.php @@ -0,0 +1,63 @@ +resolveServer($server); + + $mayConnect = $this->accessResolver->isAllowed( + $definition, + McpServerCapability::Access, + $this->securityService->getCurrentUser() + ); + if (!$mayConnect) { + throw new AccessDeniedHttpException( + sprintf('You are not allowed to use the MCP server "%s".', $server) + ); + } + + // An explicit middleware stack is passed because the SDK's default also installs + // DnsRebindingProtectionMiddleware, which literally matches Host/Origin against + // {localhost, 127.0.0.1, [::1]} and 403s anything else — incompatible with a + // reverse-proxy topology. Host validation is Pimcore's TRUSTED_HOSTS instead, + // enforced in kernel.request before this controller runs. + $transport = new StreamableHttpTransport( + request: $this->httpMessageFactory->createRequest($request), + responseFactory: $this->responseFactory, + streamFactory: $this->streamFactory, + logger: $this->logger, + middleware: [ + new CorsMiddleware(), + new ProtocolVersionMiddleware(), + ], + ); + + $response = $this->serverFactory->createServer($definition)->run($transport); + + return $this->httpFoundationFactory->createResponse($response); + } + + private function resolveServer(string $slug): McpServerDefinition + { + foreach ($this->serverRepository->list() as $definition) { + if ($definition->urlSlug === $slug && $definition->enabled) { + return $definition; + } + } + + throw new NotFoundHttpException(sprintf('MCP server "%s" not found.', $slug)); + } +} diff --git a/src/Mcp/Controller/Server/CreateServerController.php b/src/Mcp/Controller/Server/CreateServerController.php new file mode 100644 index 000000000..4c47edfa1 --- /dev/null +++ b/src/Mcp/Controller/Server/CreateServerController.php @@ -0,0 +1,78 @@ +value)] + #[Post( + path: self::PREFIX . self::ROUTE, + operationId: 'mcp_create_server', + description: 'mcp_create_server_description', + summary: 'mcp_create_server_summary', + tags: [Tags::Mcp->value] + )] + #[McpServerRequestBody] + #[SuccessResponse( + description: 'mcp_create_server_success_response', + content: new JsonContent(ref: McpServer::class) + )] + #[DefaultResponses([ + HttpResponseCodes::UNAUTHORIZED, + ])] + public function createMcpServer( + #[MapRequestPayload] McpServerParameter $parameter + ): JsonResponse { + return $this->jsonResponse( + $this->mcpServerConfigurationService->saveConfiguration($parameter) + ); + } +} diff --git a/src/Mcp/Controller/Server/DeleteServerController.php b/src/Mcp/Controller/Server/DeleteServerController.php new file mode 100644 index 000000000..ec9357fc9 --- /dev/null +++ b/src/Mcp/Controller/Server/DeleteServerController.php @@ -0,0 +1,77 @@ + '[a-z0-9-]+'], + methods: ['DELETE'], + )] + #[Delete( + path: self::PREFIX . self::ROUTE, + operationId: 'mcp_delete_server', + description: 'mcp_delete_server_description', + summary: 'mcp_delete_server_summary', + tags: [Tags::Mcp->value] + )] + #[IdParameter(type: 'MCP server', schema: new Schema(type: 'string', example: 'product-read'))] + #[SuccessResponse( + description: 'mcp_delete_server_success_response', + )] + #[DefaultResponses([ + HttpResponseCodes::UNAUTHORIZED, + HttpResponseCodes::FORBIDDEN, + HttpResponseCodes::NOT_FOUND, + ])] + public function deleteMcpServer(string $id): Response + { + $this->mcpServerConfigurationService->deleteConfiguration($id); + + return new Response(); + } +} diff --git a/src/Mcp/Controller/Server/GetServerController.php b/src/Mcp/Controller/Server/GetServerController.php new file mode 100644 index 000000000..6cb0f14b4 --- /dev/null +++ b/src/Mcp/Controller/Server/GetServerController.php @@ -0,0 +1,79 @@ + '[a-z0-9-]+'], + methods: ['GET'], + )] + #[Get( + path: self::PREFIX . self::ROUTE, + operationId: 'mcp_get_server', + description: 'mcp_get_server_description', + summary: 'mcp_get_server_summary', + tags: [Tags::Mcp->value] + )] + #[IdParameter(type: 'MCP server', schema: new Schema(type: 'string', example: 'product-read'))] + #[SuccessResponse( + description: 'mcp_get_server_success_response', + content: new JsonContent(ref: McpServer::class) + )] + #[DefaultResponses([ + HttpResponseCodes::UNAUTHORIZED, + HttpResponseCodes::FORBIDDEN, + HttpResponseCodes::NOT_FOUND, + ])] + public function getMcpServer(string $id): JsonResponse + { + return $this->jsonResponse( + $this->mcpServerConfigurationService->getConfiguration($id) + ); + } +} diff --git a/src/Mcp/Controller/Server/ListServersController.php b/src/Mcp/Controller/Server/ListServersController.php new file mode 100644 index 000000000..72ebce187 --- /dev/null +++ b/src/Mcp/Controller/Server/ListServersController.php @@ -0,0 +1,71 @@ +value] + )] + #[SuccessResponse( + description: 'mcp_get_servers_success_response', + content: new CollectionJson(new GenericCollection(McpServer::class)) + )] + #[DefaultResponses([ + HttpResponseCodes::UNAUTHORIZED, + ])] + public function getMcpServers(): JsonResponse + { + $servers = $this->mcpServerConfigurationService->listConfigurations(); + + return $this->getPaginatedCollection($this->serializer, $servers, count($servers)); + } +} diff --git a/src/Mcp/Controller/Server/UpdateServerController.php b/src/Mcp/Controller/Server/UpdateServerController.php new file mode 100644 index 000000000..34c902344 --- /dev/null +++ b/src/Mcp/Controller/Server/UpdateServerController.php @@ -0,0 +1,87 @@ + '[a-z0-9-]+'], + methods: ['PUT'], + )] + #[Put( + path: self::PREFIX . self::ROUTE, + operationId: 'mcp_update_server', + description: 'mcp_update_server_description', + summary: 'mcp_update_server_summary', + tags: [Tags::Mcp->value] + )] + #[IdParameter(type: 'MCP server', schema: new Schema(type: 'string', example: 'product-read'))] + #[McpServerRequestBody] + #[SuccessResponse( + description: 'mcp_update_server_success_response', + content: new JsonContent(ref: McpServer::class) + )] + #[DefaultResponses([ + HttpResponseCodes::UNAUTHORIZED, + HttpResponseCodes::FORBIDDEN, + HttpResponseCodes::NOT_FOUND, + ])] + public function updateMcpServer( + string $id, + #[MapRequestPayload] McpServerParameter $parameter + ): JsonResponse { + return $this->jsonResponse( + $this->mcpServerConfigurationService->updateConfiguration($id, $parameter) + ); + } +} diff --git a/src/Mcp/Controller/Tool/ListToolsController.php b/src/Mcp/Controller/Tool/ListToolsController.php new file mode 100644 index 000000000..7e7781f7a --- /dev/null +++ b/src/Mcp/Controller/Tool/ListToolsController.php @@ -0,0 +1,72 @@ +value)] + #[Get( + path: self::PREFIX . self::ROUTE, + operationId: 'mcp_get_tools', + description: 'mcp_get_tools_description', + summary: 'mcp_get_tools_summary', + tags: [Tags::Mcp->value] + )] + #[SuccessResponse( + description: 'mcp_get_tools_success_response', + content: new CollectionJson(new GenericCollection(McpToolItem::class)) + )] + #[DefaultResponses([ + HttpResponseCodes::UNAUTHORIZED, + ])] + public function getMcpTools(): JsonResponse + { + $tools = $this->mcpToolCatalogueService->listTools(); + + return $this->getPaginatedCollection($this->serializer, $tools, count($tools)); + } +} diff --git a/src/Mcp/Dto/McpServerAccess.php b/src/Mcp/Dto/McpServerAccess.php new file mode 100644 index 000000000..5c45081ef --- /dev/null +++ b/src/Mcp/Dto/McpServerAccess.php @@ -0,0 +1,92 @@ + $sharedUsers + * @param list $sharedRoles + */ + public function __construct( + public ?string $owner = null, + public bool $shareGlobal = false, + public array $sharedUsers = [], + public array $sharedRoles = [], + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + owner: isset($data['owner']) && is_string($data['owner']) && $data['owner'] !== '' ? $data['owner'] : null, + shareGlobal: (bool) ($data['share_global'] ?? false), + sharedUsers: self::entryList($data['shared_users'] ?? []), + sharedRoles: self::entryList($data['shared_roles'] ?? []), + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'owner' => $this->owner, + 'share_global' => $this->shareGlobal, + 'shared_users' => array_map(static fn (McpServerAccessEntry $e): array => $e->toArray(), $this->sharedUsers), + 'shared_roles' => array_map(static fn (McpServerAccessEntry $e): array => $e->toArray(), $this->sharedRoles), + ]; + } + + /** + * @param mixed $value + * + * @return list + */ + private static function entryList(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $entries = []; + foreach ($value as $item) { + $entry = McpServerAccessEntry::fromMixed($item); + if ($entry !== null) { + $entries[] = $entry; + } + } + + return $entries; + } +} diff --git a/src/Mcp/Dto/McpServerAccessEntry.php b/src/Mcp/Dto/McpServerAccessEntry.php new file mode 100644 index 000000000..0cc7ccf2a --- /dev/null +++ b/src/Mcp/Dto/McpServerAccessEntry.php @@ -0,0 +1,72 @@ + $this->name, + 'can_access' => $this->canAccess, + 'can_edit' => $this->canEdit, + ]; + } +} diff --git a/src/Mcp/Dto/McpServerDefinition.php b/src/Mcp/Dto/McpServerDefinition.php new file mode 100644 index 000000000..dbabbb2d2 --- /dev/null +++ b/src/Mcp/Dto/McpServerDefinition.php @@ -0,0 +1,101 @@ + $toolIds identifiers of the assigned MCP tools + * @param list $scopes OAuth scopes advertised for this server (e.g. mcp:read/mcp:write) + */ + public function __construct( + public string $id, + public string $displayName, + public string $description, + public string $urlSlug, + public array $toolIds, + public array $scopes, + public bool $enabled, + public McpServerAccess $access, + ) { + } + + /** + * @param array $data the stored config data (without the id, which is the key) + */ + public static function fromArray(string $id, array $data): self + { + return new self( + id: $id, + displayName: isset($data['name']) && is_string($data['name']) ? $data['name'] : $id, + description: isset($data['description']) && is_string($data['description']) ? $data['description'] : '', + urlSlug: isset($data['url_slug']) && is_string($data['url_slug']) && $data['url_slug'] !== '' + ? $data['url_slug'] + : $id, + toolIds: self::stringList($data['tools'] ?? []), + scopes: self::stringList($data['scopes'] ?? []), + enabled: (bool) ($data['enabled'] ?? true), + access: McpServerAccess::fromArray(is_array($data['access'] ?? null) ? $data['access'] : []), + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'name' => $this->displayName, + 'description' => $this->description, + 'url_slug' => $this->urlSlug, + 'tools' => $this->toolIds, + 'scopes' => $this->scopes, + 'enabled' => $this->enabled, + 'access' => $this->access->toArray(), + ]; + } + + /** + * @param mixed $value + * + * @return list + */ + private static function stringList(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $result = []; + foreach ($value as $item) { + if (is_string($item)) { + $result[] = $item; + } + } + + return $result; + } +} diff --git a/src/Mcp/Event/PreResponse/McpServerEvent.php b/src/Mcp/Event/PreResponse/McpServerEvent.php new file mode 100644 index 000000000..1c6eb24c3 --- /dev/null +++ b/src/Mcp/Event/PreResponse/McpServerEvent.php @@ -0,0 +1,33 @@ +server; + } +} diff --git a/src/Mcp/Event/PreResponse/McpToolItemEvent.php b/src/Mcp/Event/PreResponse/McpToolItemEvent.php new file mode 100644 index 000000000..5f1fadd9f --- /dev/null +++ b/src/Mcp/Event/PreResponse/McpToolItemEvent.php @@ -0,0 +1,33 @@ +tool; + } +} diff --git a/src/Mcp/Hydrator/McpServerHydrator.php b/src/Mcp/Hydrator/McpServerHydrator.php new file mode 100644 index 000000000..e684dce35 --- /dev/null +++ b/src/Mcp/Hydrator/McpServerHydrator.php @@ -0,0 +1,73 @@ +access; + + return new McpServer( + id: $definition->id, + name: $definition->displayName, + description: $definition->description === '' ? null : $definition->description, + urlSlug: $definition->urlSlug, + url: $url, + tools: $definition->toolIds, + scopes: $scopes, + enabled: $definition->enabled, + owner: $access->owner, + shareGlobal: $access->shareGlobal, + sharedUsers: $this->grants($access->sharedUsers), + sharedRoles: $this->grants($access->sharedRoles), + writeable: $writeable, + currentUserPermissions: $currentUserPermissions, + toolCount: count($definition->toolIds), + ); + } + + /** + * @param list $entries + * + * @return list + */ + private function grants(array $entries): array + { + return array_map( + static fn (McpServerAccessEntry $entry): McpServerAccessGrant => new McpServerAccessGrant( + $entry->name, + $entry->canAccess, + $entry->canEdit, + ), + $entries + ); + } +} diff --git a/src/Mcp/Hydrator/McpServerHydratorInterface.php b/src/Mcp/Hydrator/McpServerHydratorInterface.php new file mode 100644 index 000000000..2349100e3 --- /dev/null +++ b/src/Mcp/Hydrator/McpServerHydratorInterface.php @@ -0,0 +1,35 @@ + $scopes + */ + public function hydrate( + McpServerDefinition $definition, + string $url, + array $scopes, + bool $writeable, + McpServerUserPermissions $currentUserPermissions + ): McpServer; +} diff --git a/src/Mcp/Hydrator/McpToolItemHydrator.php b/src/Mcp/Hydrator/McpToolItemHydrator.php new file mode 100644 index 000000000..678e3e1c7 --- /dev/null +++ b/src/Mcp/Hydrator/McpToolItemHydrator.php @@ -0,0 +1,36 @@ +name, + title: $tool->title ?? $tool->name, + description: $tool->description, + requiredScope: McpScopes::forReadOnly($tool->isReadOnly()), + readOnly: $tool->isReadOnly(), + destructive: $tool->isDestructive(), + ); + } +} diff --git a/src/Mcp/Hydrator/McpToolItemHydratorInterface.php b/src/Mcp/Hydrator/McpToolItemHydratorInterface.php new file mode 100644 index 000000000..a0fa3146c --- /dev/null +++ b/src/Mcp/Hydrator/McpToolItemHydratorInterface.php @@ -0,0 +1,25 @@ + $tools + * @param list $sharedUsers user-name → level share entries + * @param list $sharedRoles role-name → level share entries + */ + public function __construct( + #[NotBlank] + private string $name, + #[NotBlank] + #[Regex(pattern: '/^[a-z0-9-]+$/', message: 'The url slug may only contain a-z, 0-9 and hyphens.')] + private string $urlSlug, + private ?string $description = null, + private array $tools = [], + private bool $enabled = true, + private bool $shareGlobal = false, + private array $sharedUsers = [], + private array $sharedRoles = [], + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getUrlSlug(): string + { + return $this->urlSlug; + } + + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return list + */ + public function getTools(): array + { + return $this->tools; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function shareGlobal(): bool + { + return $this->shareGlobal; + } + + /** + * @return list + */ + public function getSharedUsers(): array + { + return $this->sharedUsers; + } + + /** + * @return list + */ + public function getSharedRoles(): array + { + return $this->sharedRoles; + } +} diff --git a/src/Mcp/McpScopes.php b/src/Mcp/McpScopes.php new file mode 100644 index 000000000..274982885 --- /dev/null +++ b/src/Mcp/McpScopes.php @@ -0,0 +1,36 @@ +|null $outputSchema + */ + public function __construct( + public string $name, + public ?string $title, + public string $description, + public ?ToolAnnotations $annotations, + public ?array $outputSchema, + public string $className, + public string $method, + ) { + } + + public function isReadOnly(): bool + { + return $this->annotations?->readOnlyHint === true; + } + + public function isDestructive(): bool + { + return $this->annotations?->destructiveHint === true; + } +} diff --git a/src/Mcp/Registry/McpToolRegistry.php b/src/Mcp/Registry/McpToolRegistry.php new file mode 100644 index 000000000..d95a2239a --- /dev/null +++ b/src/Mcp/Registry/McpToolRegistry.php @@ -0,0 +1,109 @@ +|null, + * outputSchema: array|null + * } + */ +final class McpToolRegistry implements McpToolRegistryInterface +{ + public const string TAG = 'pimcore.studio_backend.mcp_tool'; + + /** + * @var array|null + */ + private ?array $resolved = null; + + /** + * @param array $toolMetadata name-keyed tool descriptors + */ + public function __construct( + private readonly array $toolMetadata = [], + private readonly ?ContainerInterface $toolLocator = null, + ) { + } + + public function all(): array + { + return array_values($this->references()); + } + + public function has(string $name): bool + { + return isset($this->references()[$name]); + } + + public function get(string $name): ?McpToolReference + { + return $this->references()[$name] ?? null; + } + + public function names(): array + { + return array_keys($this->references()); + } + + public function getLocator(): ContainerInterface + { + return $this->toolLocator ?? new ServiceLocator([]); + } + + /** + * @return array + */ + private function references(): array + { + if ($this->resolved !== null) { + return $this->resolved; + } + + $references = []; + foreach ($this->toolMetadata as $name => $meta) { + $annotations = $meta['annotations']; + $references[$name] = new McpToolReference( + name: $name, + title: $meta['title'], + description: $meta['description'], + annotations: is_array($annotations) ? ToolAnnotations::fromArray($annotations) : null, + outputSchema: $meta['outputSchema'], + className: $meta['class'], + method: $meta['method'], + ); + } + + return $this->resolved = $references; + } +} diff --git a/src/Mcp/Registry/McpToolRegistryInterface.php b/src/Mcp/Registry/McpToolRegistryInterface.php new file mode 100644 index 000000000..11a219b6d --- /dev/null +++ b/src/Mcp/Registry/McpToolRegistryInterface.php @@ -0,0 +1,46 @@ + + */ + public function all(): array; + + public function has(string $name): bool; + + public function get(string $name): ?McpToolReference; + + /** + * @return list + */ + public function names(): array; + + /** + * PSR container that resolves a tool's backing service by class, for the SDK + * server to invoke `[class, method]` handlers. + */ + public function getLocator(): ContainerInterface; +} diff --git a/src/Mcp/Repository/McpServerConfigRepository.php b/src/Mcp/Repository/McpServerConfigRepository.php new file mode 100644 index 000000000..37134177e --- /dev/null +++ b/src/Mcp/Repository/McpServerConfigRepository.php @@ -0,0 +1,155 @@ + $serverConfigurations shipped server seed (the studio_mcp_servers node) + * @param array $storageConfig the config_location.studio_mcp_servers subtree + * @param LocationAwareConfigRepository|null $repository pre-built backend; null in production (built lazily), + * injected as an in-memory backend by tests + */ + public function __construct( + private readonly array $serverConfigurations, + private readonly array $storageConfig, + ?LocationAwareConfigRepository $repository = null, + ) { + $this->repository = $repository; + } + + public function list(): array + { + $servers = []; + foreach ($this->getRepository()->fetchAllKeys() as $id) { + $servers[] = $this->get((string) $id); + } + + return $servers; + } + + public function has(string $id): bool + { + return $this->getRepository()->loadConfigByKey($id)[0] !== null; + } + + public function isWriteable(): bool + { + try { + return $this->getRepository()->isWriteable(); + } catch (Exception) { + return false; + } + } + + public function get(string $id): McpServerDefinition + { + [$data] = $this->getRepository()->loadConfigByKey($id); + if ($data === null) { + throw new NotFoundException('MCP server', $id); + } + + return McpServerDefinition::fromArray($id, $data); + } + + public function save(McpServerDefinition $server): void + { + $this->assertWriteable(); + + try { + $this->getRepository()->saveConfig( + $server->id, + $server->toArray(), + static fn (string $key, mixed $data): array => [ + Configuration::ROOT_NODE => [ + Configuration::MCP_SERVERS_NODE => [ + $key => $data, + ], + ], + ] + ); + } catch (Exception $exception) { + throw new ElementSavingFailedException(null, $exception->getMessage()); + } + } + + public function delete(string $id): void + { + if (!$this->has($id)) { + throw new NotFoundException('MCP server', $id); + } + + try { + $repository = $this->getRepository(); + $repository->deleteData($id, $repository->getWriteTarget()); + } catch (Exception $exception) { + throw new NotWriteableException( + 'mcp_server', + sprintf('MCP server "%s" could not be deleted: %s', $id, $exception->getMessage()), + $exception + ); + } + } + + private function getRepository(): LocationAwareConfigRepository + { + return $this->repository ??= new LocationAwareConfigRepository( + $this->serverConfigurations, + Configuration::MCP_SERVERS_NODE, + $this->storageConfig, + ); + } + + /** + * @throws NotWriteableException + */ + private function assertWriteable(): void + { + try { + if (!$this->getRepository()->isWriteable()) { + throw new NotWriteableException( + 'mcp_server', + 'The MCP server configuration storage is not writeable.' + ); + } + } catch (NotWriteableException $exception) { + throw $exception; + } catch (Exception $exception) { + throw new NotWriteableException( + 'mcp_server', + sprintf('The MCP server configuration could not be written: %s', $exception->getMessage()), + $exception + ); + } + } +} diff --git a/src/Mcp/Repository/McpServerConfigRepositoryInterface.php b/src/Mcp/Repository/McpServerConfigRepositoryInterface.php new file mode 100644 index 000000000..b3d91e109 --- /dev/null +++ b/src/Mcp/Repository/McpServerConfigRepositoryInterface.php @@ -0,0 +1,59 @@ + + * + * @throws NotFoundException + */ + public function list(): array; + + public function has(string $id): bool; + + /** + * Whether the configured storage target accepts writes (false e.g. for a + * symfony-config target outside debug mode). + */ + public function isWriteable(): bool; + + /** + * @throws NotFoundException when no server with that id exists + */ + public function get(string $id): McpServerDefinition; + + /** + * @throws NotWriteableException when the configured storage target is read-only + */ + public function save(McpServerDefinition $server): void; + + /** + * @throws NotFoundException when no server with that id exists + * @throws NotWriteableException when the configured storage target is read-only + */ + public function delete(string $id): void; +} diff --git a/src/Mcp/Schema/McpServer.php b/src/Mcp/Schema/McpServer.php new file mode 100644 index 000000000..a4b6e25fa --- /dev/null +++ b/src/Mcp/Schema/McpServer.php @@ -0,0 +1,175 @@ + $tools + * @param list $scopes + * @param list $sharedUsers + * @param list $sharedRoles + */ + public function __construct( + #[Property(description: 'Server id (also the url slug)', type: 'string', example: 'product-read')] + private readonly string $id, + #[Property(description: 'Display name', type: 'string', example: 'Product (read-only)')] + private readonly string $name, + #[Property(description: 'Description', type: 'string', example: 'Read access to product data', nullable: true)] + private readonly ?string $description, + #[Property(description: 'URL segment under /pimcore-mcp/studio/', type: 'string', example: 'product-read')] + private readonly string $urlSlug, + #[Property(description: 'Endpoint an MCP client connects to', type: 'string', example: 'https://host/pimcore-mcp/studio/product-read')] + private readonly string $url, + #[Property(description: 'Assigned tool ids', type: 'array', items: new Items(type: 'string'), example: ['get_car_info'])] + private readonly array $tools, + #[Property(description: 'Advertised OAuth scopes (derived from the tools)', type: 'array', items: new Items(type: 'string'), example: ['mcp:read'])] + private readonly array $scopes, + #[Property(description: 'Whether the server is enabled', type: 'boolean', example: true)] + private readonly bool $enabled, + #[Property(description: 'Owner user name. Null when the owner has been deleted.', type: 'string', example: 'john.doe', nullable: true)] + private readonly ?string $owner, + #[Property(description: 'Public: any authenticated user may view and use it (not edit)', type: 'boolean', example: false)] + private readonly bool $shareGlobal, + #[Property(description: 'Users shared with, each at a read/write level', type: 'array', items: new Items(ref: McpServerAccessGrant::class))] + private readonly array $sharedUsers, + #[Property(description: 'Roles shared with, each at a read/write level', type: 'array', items: new Items(ref: McpServerAccessGrant::class))] + private readonly array $sharedRoles, + #[Property(description: 'Whether the storage target allows editing at all', type: 'boolean', example: true)] + private readonly bool $writeable, + #[Property(description: 'The requesting user\'s resolved access to this server', ref: McpServerUserPermissions::class)] + private readonly McpServerUserPermissions $currentUserPermissions, + #[Property(description: 'Number of assigned tools', type: 'integer', example: 1)] + private readonly int $toolCount, + ) { + } + + public function getId(): string + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getUrlSlug(): string + { + return $this->urlSlug; + } + + public function getUrl(): string + { + return $this->url; + } + + /** + * @return list + */ + public function getTools(): array + { + return $this->tools; + } + + /** + * @return list + */ + public function getScopes(): array + { + return $this->scopes; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function getOwner(): ?string + { + return $this->owner; + } + + public function isShareGlobal(): bool + { + return $this->shareGlobal; + } + + /** + * @return list + */ + public function getSharedUsers(): array + { + return $this->sharedUsers; + } + + /** + * @return list + */ + public function getSharedRoles(): array + { + return $this->sharedRoles; + } + + public function isWriteable(): bool + { + return $this->writeable; + } + + public function getCurrentUserPermissions(): McpServerUserPermissions + { + return $this->currentUserPermissions; + } + + public function getToolCount(): int + { + return $this->toolCount; + } +} diff --git a/src/Mcp/Schema/McpServerAccessGrant.php b/src/Mcp/Schema/McpServerAccessGrant.php new file mode 100644 index 000000000..a57b234ef --- /dev/null +++ b/src/Mcp/Schema/McpServerAccessGrant.php @@ -0,0 +1,58 @@ +name; + } + + public function isCanAccess(): bool + { + return $this->canAccess; + } + + public function isCanEdit(): bool + { + return $this->canEdit; + } +} diff --git a/src/Mcp/Schema/McpServerUserPermissions.php b/src/Mcp/Schema/McpServerUserPermissions.php new file mode 100644 index 000000000..016ed1fe0 --- /dev/null +++ b/src/Mcp/Schema/McpServerUserPermissions.php @@ -0,0 +1,58 @@ +canView; + } + + public function isCanAccess(): bool + { + return $this->canAccess; + } + + public function isCanEdit(): bool + { + return $this->canEdit; + } +} diff --git a/src/Mcp/Schema/McpToolItem.php b/src/Mcp/Schema/McpToolItem.php new file mode 100644 index 000000000..b5cec73f4 --- /dev/null +++ b/src/Mcp/Schema/McpToolItem.php @@ -0,0 +1,81 @@ +name; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getRequiredScope(): string + { + return $this->requiredScope; + } + + public function isReadOnly(): bool + { + return $this->readOnly; + } + + public function isDestructive(): bool + { + return $this->destructive; + } +} diff --git a/src/Mcp/Security/McpServerAccessResolver.php b/src/Mcp/Security/McpServerAccessResolver.php new file mode 100644 index 000000000..919fc30fd --- /dev/null +++ b/src/Mcp/Security/McpServerAccessResolver.php @@ -0,0 +1,129 @@ +resolve($server, $user); + + return match ($capability) { + McpServerCapability::View => $resolved['view'], + McpServerCapability::Access => $resolved['access'], + McpServerCapability::Edit => $resolved['edit'], + }; + } + + public function resolve(McpServerDefinition $server, UserInterface $user): array + { + $access = $server->access; + $isAdmin = $user->isAdmin(); + $isOwner = $access->owner !== null && $access->owner !== '' && $user->getName() === $access->owner; + + $entries = $this->matchingEntries($access->sharedUsers, $access->sharedRoles, $user); + $listed = $entries !== []; + + $entryAccess = false; + $entryEdit = false; + foreach ($entries as $entry) { + $entryAccess = $entryAccess || $entry->canAccess; + $entryEdit = $entryEdit || $entry->canEdit; + } + + return [ + 'view' => $isAdmin || $isOwner || $access->shareGlobal || $listed, + 'access' => $access->shareGlobal || $entryAccess, + 'edit' => $isAdmin || $isOwner || $entryEdit, + ]; + } + + /** + * The user's own entry plus any of their role entries. + * + * @param list $userEntries + * @param list $roleEntries + * + * @return list + */ + private function matchingEntries(array $userEntries, array $roleEntries, UserInterface $user): array + { + $matched = []; + + $userName = $user->getName(); + if ($userName !== null) { + foreach ($userEntries as $entry) { + if ($entry->name === $userName) { + $matched[] = $entry; + } + } + } + + $roleNames = $this->roleNames($user); + foreach ($roleEntries as $entry) { + if (in_array($entry->name, $roleNames, true)) { + $matched[] = $entry; + } + } + + return $matched; + } + + /** + * The names of the roles the user holds. {@see UserInterface::getRoles()} + * yields role ids, so each is resolved to its name for matching. + * + * @return list + */ + private function roleNames(UserInterface $user): array + { + $names = []; + foreach ($user->getRoles() as $roleId) { + $role = $this->roleResolver->getById($roleId); + if ($role !== null) { + $names[] = $role->getName(); + } + } + + return $names; + } +} diff --git a/src/Mcp/Security/McpServerAccessResolverInterface.php b/src/Mcp/Security/McpServerAccessResolverInterface.php new file mode 100644 index 000000000..329943756 --- /dev/null +++ b/src/Mcp/Security/McpServerAccessResolverInterface.php @@ -0,0 +1,40 @@ + + */ + private array $servers = []; + + public function __construct( + private readonly McpToolRegistryInterface $toolRegistry, + private readonly CacheInterface $sessionCache, + private readonly LoggerInterface $logger, + ) { + } + + public function createServer(McpServerDefinition $server): Server + { + if (isset($this->servers[$server->id])) { + return $this->servers[$server->id]; + } + + $builder = Server::builder() + ->setServerInfo('pimcore-' . $server->urlSlug, self::SERVER_VERSION, $server->description) + ->setCapabilities(new ServerCapabilities( + tools: true, + resources: false, + prompts: false, + logging: false, + )) + ->setSession( + sessionStore: new Psr16SessionStore( + $this->sessionCache, + 'mcp_studio_' . $server->urlSlug . '_', + self::SESSION_TTL, + ) + ) + ->setContainer($this->toolRegistry->getLocator()) + ->setLogger($this->logger); + + foreach ($server->toolIds as $toolId) { + $tool = $this->toolRegistry->get($toolId); + if ($tool === null) { + $this->logger->warning( + 'MCP server "{server}" references unknown tool "{tool}"; skipping.', + ['server' => $server->id, 'tool' => $toolId] + ); + + continue; + } + + $this->registerTool($builder, $tool); + } + + return $this->servers[$server->id] = $builder->build(); + } + + private function registerTool(Builder $builder, McpToolReference $tool): void + { + $builder->addTool( + handler: [$tool->className, $tool->method], + name: $tool->name, + title: $tool->title, + description: $tool->description, + annotations: $tool->annotations, + inputSchema: $this->buildInputSchema($tool->className, $tool->method), + outputSchema: $tool->outputSchema, + ); + } + + /** + * The SDK can derive the input schema from `#[Schema]` parameter attributes, but + * it is generated here so {@see ToolInputSchemaNormalizer} can repair the + * generated parameter types before registration. + * + * @param class-string $class + * + * @return array + */ + private function buildInputSchema(string $class, string $method): array + { + $generator = new SchemaGenerator(new DocBlockParser()); + + return ToolInputSchemaNormalizer::normalize($generator->generate(new ReflectionMethod($class, $method))); + } +} diff --git a/src/Mcp/Server/McpServerFactoryInterface.php b/src/Mcp/Server/McpServerFactoryInterface.php new file mode 100644 index 000000000..80ebd9260 --- /dev/null +++ b/src/Mcp/Server/McpServerFactoryInterface.php @@ -0,0 +1,27 @@ +securityService->getCurrentUser(); + + $servers = []; + foreach ($this->repository->list() as $definition) { + if ($this->accessResolver->isAllowed($definition, McpServerCapability::View, $user)) { + $servers[] = $this->buildServer($definition); + } + } + + return $servers; + } + + public function getConfiguration(string $id): McpServer + { + $definition = $this->repository->get($id); + $this->assert($definition, McpServerCapability::View); + + return $this->buildServer($definition); + } + + public function saveConfiguration(McpServerParameter $parameter): McpServer + { + $id = $parameter->getUrlSlug(); + if ($this->repository->has($id)) { + throw new ElementExistsException( + sprintf('An MCP server with the id "%s" already exists.', $id) + ); + } + + $definition = $this->buildDefinition( + $id, + $parameter, + $this->securityService->getCurrentUser()->getName() + ); + $this->repository->save($definition); + + return $this->buildServer($definition); + } + + public function updateConfiguration(string $id, McpServerParameter $parameter): McpServer + { + // Editing (incl. re-sharing) requires the Edit capability; the slug is + // locked to the id and the original owner is preserved. + $existing = $this->repository->get($id); + $this->assert($existing, McpServerCapability::Edit); + + $definition = $this->buildDefinition($id, $parameter, $existing->access->owner); + $this->repository->save($definition); + + return $this->buildServer($definition); + } + + public function deleteConfiguration(string $id): void + { + $definition = $this->repository->get($id); + $this->assert($definition, McpServerCapability::Edit); + + $this->repository->delete($id); + } + + private function buildDefinition(string $id, McpServerParameter $parameter, ?string $owner): McpServerDefinition + { + return new McpServerDefinition( + id: $id, + displayName: $parameter->getName(), + description: $parameter->getDescription() ?? '', + urlSlug: $id, + toolIds: $parameter->getTools(), + scopes: $this->deriveScopes($parameter->getTools()), + enabled: $parameter->isEnabled(), + access: new McpServerAccess( + owner: $owner, + shareGlobal: $parameter->shareGlobal(), + // The owner is not seeded into the grid: they get implicit read + edit + // via the resolver, and must grant themselves Access explicitly. + sharedUsers: $this->normalizeEntries($parameter->getSharedUsers()), + sharedRoles: $this->normalizeEntries($parameter->getSharedRoles()), + ), + ); + } + + private function buildServer(McpServerDefinition $definition): McpServer + { + $resolved = $this->accessResolver->resolve($definition, $this->securityService->getCurrentUser()); + + $server = $this->serverHydrator->hydrate( + $definition, + $this->buildUrl($definition->urlSlug), + $this->deriveScopes($definition->toolIds), + $this->repository->isWriteable(), + new McpServerUserPermissions($resolved['view'], $resolved['access'], $resolved['edit']), + ); + + $this->eventDispatcher->dispatch(new McpServerEvent($server), McpServerEvent::EVENT_NAME); + + return $server; + } + + /** + * @throws ForbiddenException + */ + private function assert(McpServerDefinition $definition, McpServerCapability $capability): void + { + if (!$this->accessResolver->isAllowed($definition, $capability, $this->securityService->getCurrentUser())) { + throw new ForbiddenException( + sprintf('You are not allowed to %s the MCP server "%s".', $capability->value, $definition->id) + ); + } + } + + /** + * @param list $raw + * + * @return list + */ + private function normalizeEntries(array $raw): array + { + $entries = []; + foreach ($raw as $item) { + $entry = McpServerAccessEntry::fromMixed($item); + if ($entry !== null) { + $entries[] = $entry; + } + } + + return $entries; + } + + /** + * @param list $toolIds + * + * @return list + */ + private function deriveScopes(array $toolIds): array + { + $scopes = []; + foreach ($toolIds as $toolId) { + $tool = $this->toolRegistry->get($toolId); + if ($tool !== null) { + $scopes[McpScopes::forReadOnly($tool->isReadOnly())] = true; + } + } + + return array_keys($scopes); + } + + private function buildUrl(string $slug): string + { + return rtrim($this->issuer ?? '', '/') . '/pimcore-mcp/studio/' . $slug; + } +} diff --git a/src/Mcp/Service/McpServerConfigurationServiceInterface.php b/src/Mcp/Service/McpServerConfigurationServiceInterface.php new file mode 100644 index 000000000..69b3dee74 --- /dev/null +++ b/src/Mcp/Service/McpServerConfigurationServiceInterface.php @@ -0,0 +1,53 @@ + + */ + public function listConfigurations(): array; + + /** + * @throws ForbiddenException|NotFoundException + */ + public function getConfiguration(string $id): McpServer; + + /** + * @throws ElementExistsException|ElementSavingFailedException|NotWriteableException + */ + public function saveConfiguration(McpServerParameter $parameter): McpServer; + + /** + * @throws ElementSavingFailedException|ForbiddenException|NotFoundException|NotWriteableException + */ + public function updateConfiguration(string $id, McpServerParameter $parameter): McpServer; + + /** + * @throws ForbiddenException|NotFoundException|NotWriteableException + */ + public function deleteConfiguration(string $id): void; +} diff --git a/src/Mcp/Service/McpToolCatalogueService.php b/src/Mcp/Service/McpToolCatalogueService.php new file mode 100644 index 000000000..36d74f59b --- /dev/null +++ b/src/Mcp/Service/McpToolCatalogueService.php @@ -0,0 +1,44 @@ +toolRegistry->all() as $tool) { + $item = $this->toolHydrator->hydrate($tool); + $this->eventDispatcher->dispatch(new McpToolItemEvent($item), McpToolItemEvent::EVENT_NAME); + $items[] = $item; + } + + return $items; + } +} diff --git a/src/Mcp/Service/McpToolCatalogueServiceInterface.php b/src/Mcp/Service/McpToolCatalogueServiceInterface.php new file mode 100644 index 000000000..3205f2b64 --- /dev/null +++ b/src/Mcp/Service/McpToolCatalogueServiceInterface.php @@ -0,0 +1,27 @@ + + */ + public function listTools(): array; +} diff --git a/src/Mcp/Tool/Builtin/PingTool.php b/src/Mcp/Tool/Builtin/PingTool.php new file mode 100644 index 000000000..1505bcfd8 --- /dev/null +++ b/src/Mcp/Tool/Builtin/PingTool.php @@ -0,0 +1,48 @@ +value . '" user permission definition'; + } + + public function up(Schema $schema): void + { + $table = UserPermissions::DEFINITIONS_TABLE->value; + + // Idempotent: a fresh install adds this via the installer, and forward-merges across release + // lines can replay the change, so only insert when the row is absent. + $exists = $this->connection->fetchOne( + 'SELECT `key` FROM `' . $table . '` WHERE `key` = ?', + [UserPermissions::MCP_SERVERS->value] + ); + + if ($exists !== false) { + return; + } + + $this->addSql( + 'INSERT INTO `' . $table . '` (`key`, `category`) VALUES (?, ?)', + [UserPermissions::MCP_SERVERS->value, UserPermissions::PERMISSIONS_CATEGORY->value] + ); + } + + public function down(Schema $schema): void + { + $table = UserPermissions::DEFINITIONS_TABLE->value; + + $exists = $this->connection->fetchOne( + 'SELECT `key` FROM `' . $table . '` WHERE `key` = ?', + [UserPermissions::MCP_SERVERS->value] + ); + + if ($exists === false) { + return; + } + + $this->addSql( + 'DELETE FROM `' . $table . '` WHERE `key` = ?', + [UserPermissions::MCP_SERVERS->value] + ); + } + + /** + * The permission voter caches the permission-key list; drop it so the change takes effect + * without a separate cache clear. Mirrors how the list is stored (see UserPermissionVoter). + */ + public function postUp(Schema $schema): void + { + Cache::remove(CacheKeys::USER_PERMISSIONS->value); + } + + public function postDown(Schema $schema): void + { + Cache::remove(CacheKeys::USER_PERMISSIONS->value); + } +} diff --git a/src/Migrations/Version20260901120000.php b/src/Migrations/Version20260901120000.php new file mode 100644 index 000000000..a6b643850 --- /dev/null +++ b/src/Migrations/Version20260901120000.php @@ -0,0 +1,52 @@ +addSql( + 'ALTER TABLE ' . OAuthTokenRecord::TABLE_NAME . ' ADD COLUMN IF NOT EXISTS resource VARCHAR(512) NULL' + ); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE ' . OAuthTokenRecord::TABLE_NAME . ' DROP COLUMN IF EXISTS resource'); + } +} diff --git a/src/OAuth/Contract/ResourceRegistryInterface.php b/src/OAuth/Contract/ResourceRegistryInterface.php index 6c55a18f7..9f106e01c 100644 --- a/src/OAuth/Contract/ResourceRegistryInterface.php +++ b/src/OAuth/Contract/ResourceRegistryInterface.php @@ -20,8 +20,11 @@ * Registry of OAuth protected resources (audiences). * * Supports multiple resources; lookups are keyed by the canonical resource URI. + * Implementations canonicalise on both registration and lookup, so callers may + * pass any equivalent form of a URI. * - * @internal + * Public API. Bundles whose endpoints are protected resources register them here, + * which is what makes their RFC 9728 metadata document resolvable. */ interface ResourceRegistryInterface { diff --git a/src/OAuth/Contract/ScopeProviderInterface.php b/src/OAuth/Contract/ScopeProviderInterface.php new file mode 100644 index 000000000..c09124934 --- /dev/null +++ b/src/OAuth/Contract/ScopeProviderInterface.php @@ -0,0 +1,39 @@ + + */ + public function scopes(): array; +} diff --git a/src/OAuth/Contract/ScopeRegistryInterface.php b/src/OAuth/Contract/ScopeRegistryInterface.php new file mode 100644 index 000000000..831150b4c --- /dev/null +++ b/src/OAuth/Contract/ScopeRegistryInterface.php @@ -0,0 +1,36 @@ + + */ + public function all(): array; + + public function has(string $scope): bool; +} diff --git a/src/OAuth/Contract/TokenValidatorInterface.php b/src/OAuth/Contract/TokenValidatorInterface.php index 2de47374b..14c1adef3 100644 --- a/src/OAuth/Contract/TokenValidatorInterface.php +++ b/src/OAuth/Contract/TokenValidatorInterface.php @@ -23,7 +23,9 @@ * source (embedded AS today, external IdP later) is swappable without changing * the endpoints. * - * @internal + * Public API. Bundles that expose their own OAuth-protected endpoints implement + * an authenticator against this contract rather than duplicating token parsing, + * signature and revocation checks. */ interface TokenValidatorInterface { diff --git a/src/OAuth/Controller/AuthorizationServerMetadataController.php b/src/OAuth/Controller/AuthorizationServerMetadataController.php index e75184389..57e2a1439 100644 --- a/src/OAuth/Controller/AuthorizationServerMetadataController.php +++ b/src/OAuth/Controller/AuthorizationServerMetadataController.php @@ -13,6 +13,7 @@ namespace Pimcore\Bundle\StudioBackendBundle\OAuth\Controller; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\ScopeRegistryInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -28,6 +29,7 @@ final class AuthorizationServerMetadataController { public function __construct( private readonly ?string $issuer, + private readonly ScopeRegistryInterface $scopeRegistry, private readonly bool $clientIdMetadataDocumentSupported = false, private readonly bool $registrationEnabled = false, ) { @@ -45,7 +47,7 @@ public function __invoke(Request $request): JsonResponse 'response_types_supported' => ['code'], 'code_challenge_methods_supported' => ['S256'], 'token_endpoint_auth_methods_supported' => ['client_secret_post', 'client_secret_basic', 'none'], - 'scopes_supported' => ['mcp:read', 'mcp:write'], + 'scopes_supported' => $this->scopeRegistry->all(), 'authorization_response_iss_parameter_supported' => true, // CIMD: clients may present an HTTPS URL as client_id (no registration). 'client_id_metadata_document_supported' => $this->clientIdMetadataDocumentSupported, diff --git a/src/OAuth/Dto/ProtectedResource.php b/src/OAuth/Dto/ProtectedResource.php index 304911118..306dd3bd6 100644 --- a/src/OAuth/Dto/ProtectedResource.php +++ b/src/OAuth/Dto/ProtectedResource.php @@ -17,10 +17,11 @@ * A protected resource (OAuth audience): its canonical URI, the scopes it * supports, and the authorization server(s) that may issue tokens for it. * - * `$canonicalUri` is expected to already be canonicalised - * (see {@see \Pimcore\Bundle\StudioBackendBundle\OAuth\Util\CanonicalUri}). + * `$canonicalUri` need not be pre-canonicalised: the registry canonicalises on + * registration and on every lookup. * - * @internal + * Public API: the argument type of + * {@see \Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\ResourceRegistryInterface::register()}. */ final readonly class ProtectedResource { diff --git a/src/OAuth/Dto/ProtectedResourceMetadata.php b/src/OAuth/Dto/ProtectedResourceMetadata.php index 3ab93e9b8..344e4a7a2 100644 --- a/src/OAuth/Dto/ProtectedResourceMetadata.php +++ b/src/OAuth/Dto/ProtectedResourceMetadata.php @@ -20,7 +20,8 @@ * `WWW-Authenticate` challenge on the MCP endpoint. Bearer tokens are accepted * in the header only. * - * @internal + * Public API: the return type of + * {@see \Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\ResourceRegistryInterface::metadataFor()}. */ final readonly class ProtectedResourceMetadata { diff --git a/src/OAuth/Dto/ResolvedAccess.php b/src/OAuth/Dto/ResolvedAccess.php index 6cf648fc0..db9097e40 100644 --- a/src/OAuth/Dto/ResolvedAccess.php +++ b/src/OAuth/Dto/ResolvedAccess.php @@ -20,7 +20,8 @@ * Pimcore user plus the OAuth context (granted scopes, validated audience, * client id). * - * @internal + * Public API: the return type of + * {@see \Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\TokenValidatorInterface::validate()}. */ final readonly class ResolvedAccess { diff --git a/src/OAuth/Registry/ConfigProtectedResourceRegistry.php b/src/OAuth/Registry/ConfigProtectedResourceRegistry.php index 871b00707..889f1efa7 100644 --- a/src/OAuth/Registry/ConfigProtectedResourceRegistry.php +++ b/src/OAuth/Registry/ConfigProtectedResourceRegistry.php @@ -56,7 +56,16 @@ public function __construct(array $resources = []) public function register(ProtectedResource $resource): void { - $this->resources[CanonicalUri::canonicalize($resource->canonicalUri)] = $resource; + // Canonicalise the resource itself, not only the lookup key: the metadata + // document echoes `canonicalUri` back as the RFC 9728 `resource` value, which + // must be the canonical form whatever a caller registered. + $canonicalUri = CanonicalUri::canonicalize($resource->canonicalUri); + + $this->resources[$canonicalUri] = new ProtectedResource( + $canonicalUri, + $resource->scopesSupported, + $resource->authorizationServers, + ); } public function has(string $canonicalUri): bool diff --git a/src/OAuth/Registry/ScopeRegistry.php b/src/OAuth/Registry/ScopeRegistry.php new file mode 100644 index 000000000..34bc63ca0 --- /dev/null +++ b/src/OAuth/Registry/ScopeRegistry.php @@ -0,0 +1,62 @@ +|null + */ + private ?array $scopes = null; + + /** + * @param iterable $providers + */ + public function __construct( + private readonly iterable $providers, + ) { + } + + public function all(): array + { + if ($this->scopes !== null) { + return $this->scopes; + } + + $scopes = []; + foreach ($this->providers as $provider) { + foreach ($provider->scopes() as $scope) { + if ($scope !== '' && !in_array($scope, $scopes, true)) { + $scopes[] = $scope; + } + } + } + + $this->scopes = $scopes; + + return $this->scopes; + } + + public function has(string $scope): bool + { + return in_array($scope, $this->all(), true); + } +} diff --git a/src/OAuth/Scope/McpScopeProvider.php b/src/OAuth/Scope/McpScopeProvider.php new file mode 100644 index 000000000..a16fcbd00 --- /dev/null +++ b/src/OAuth/Scope/McpScopeProvider.php @@ -0,0 +1,30 @@ +refreshTokenRepository, $this->secondsInterval($this->authCodeTtl), $this->allowLocalhostLoopback, + $this->resourceRegistry, + $this->tokenRecordStore, ); $authCodeGrant->setRefreshTokenTTL($refreshTokenTtl); $server->enableGrantType($authCodeGrant, $accessTokenTtl); - $refreshTokenGrant = new RefreshTokenGrant($this->refreshTokenRepository); + $refreshTokenGrant = new ResourceRefreshTokenGrant($this->refreshTokenRepository, $this->tokenRecordStore); $refreshTokenGrant->setRefreshTokenTTL($refreshTokenTtl); $server->enableGrantType($refreshTokenGrant, $accessTokenTtl); diff --git a/src/OAuth/Server/ClientRegistrar.php b/src/OAuth/Server/ClientRegistrar.php index f1cd03f8e..a56dcd43c 100644 --- a/src/OAuth/Server/ClientRegistrar.php +++ b/src/OAuth/Server/ClientRegistrar.php @@ -13,6 +13,7 @@ namespace Pimcore\Bundle\StudioBackendBundle\OAuth\Server; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\ScopeRegistryInterface; use Pimcore\Bundle\StudioBackendBundle\OAuth\Dto\DynamicClient; use Pimcore\Bundle\StudioBackendBundle\OAuth\Dto\RegisteredClient; use Pimcore\Bundle\StudioBackendBundle\OAuth\Exception\ClientRegistrationException; @@ -40,8 +41,6 @@ */ final readonly class ClientRegistrar { - private const array SUPPORTED_SCOPES = ['mcp:read', 'mcp:write']; - private const array SUPPORTED_GRANTS = ['authorization_code', 'refresh_token']; private const array AUTH_METHODS = ['none', 'client_secret_basic', 'client_secret_post']; @@ -50,6 +49,7 @@ public function __construct( private DynamicClientStoreInterface $store, + private ScopeRegistryInterface $scopeRegistry, ) { } @@ -195,8 +195,11 @@ private function parseGrantTypes(mixed $value): array */ private function parseScopes(mixed $value): array { + // No scope requested means no scope. Defaulting to "the first registered scope" + // would depend on bundle registration order, so the same client registration + // would yield different scopes on different installations. if ($value === null || $value === '') { - return ['mcp:read']; + return []; } if (!is_string($value)) { @@ -205,7 +208,7 @@ private function parseScopes(mixed $value): array $requested = preg_split('/\s+/u', trim($value)) ?: []; foreach ($requested as $scope) { - if (!in_array($scope, self::SUPPORTED_SCOPES, true)) { + if (!$this->scopeRegistry->has($scope)) { throw new ClientRegistrationException('invalid_client_metadata', 'Unsupported scope: ' . $scope); } } diff --git a/src/OAuth/Server/Entity/AccessTokenEntity.php b/src/OAuth/Server/Entity/AccessTokenEntity.php index dc167fef9..a33edcaa2 100644 --- a/src/OAuth/Server/Entity/AccessTokenEntity.php +++ b/src/OAuth/Server/Entity/AccessTokenEntity.php @@ -27,9 +27,9 @@ /** * JWT access token. Replaces league's default AccessTokenTrait so the token - * carries RFC 9068 claims — a space-delimited `scope` string, `client_id`, and - * `iss` — instead of league's `aud`=client-id / `scopes` array. Resource - * audience binding is added later. + * carries RFC 9068 claims: a space-delimited `scope` string, `client_id`, `iss`, + * and an `aud` naming the resource the token was requested for (RFC 8707), rather + * than league's `aud`=client-id / `scopes` array. * * @internal */ @@ -42,6 +42,8 @@ final class AccessTokenEntity implements AccessTokenEntityInterface private ?string $issuer = null; + private ?string $audience = null; + public function setPrivateKey(CryptKeyInterface $privateKey): void { $this->privateKey = $privateKey; @@ -52,6 +54,20 @@ public function setIssuer(?string $issuer): void $this->issuer = $issuer; } + /** + * The resource this token is for (RFC 8707). Null leaves `aud` off the token, + * which is what a request that named no resource gets. + */ + public function setAudience(?string $audience): void + { + $this->audience = $audience; + } + + public function getAudience(): ?string + { + return $this->audience; + } + public function toString(): string { $configuration = Configuration::forAsymmetricSigner( @@ -74,6 +90,10 @@ public function toString(): string $builder = $builder->issuedBy($this->issuer); } + if ($this->audience !== null) { + $builder = $builder->permittedFor($this->audience); + } + return $builder->getToken($configuration->signer(), $configuration->signingKey())->toString(); } diff --git a/src/OAuth/Server/Grant/LoopbackAuthCodeGrant.php b/src/OAuth/Server/Grant/LoopbackAuthCodeGrant.php index c95655612..1f0ed00ba 100644 --- a/src/OAuth/Server/Grant/LoopbackAuthCodeGrant.php +++ b/src/OAuth/Server/Grant/LoopbackAuthCodeGrant.php @@ -14,15 +14,29 @@ namespace Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Grant; use DateInterval; +use Exception; +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\AuthCodeEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Exception\OAuthServerException; +use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; use League\OAuth2\Server\Grant\AuthCodeGrant; use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use League\OAuth2\Server\RequestEvent; use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface; +use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\ResourceRegistryInterface; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Entity\AccessTokenEntity; use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\RedirectUri\LoopbackRedirectUriValidator; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Repository\TokenRecordStoreInterface; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\RequestType\ResourceAuthorizationRequest; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Util\CanonicalUri; use Psr\Http\Message\ServerRequestInterface; +use function is_array; +use function is_string; +use function json_decode; /** * Authorization-code grant that validates redirect URIs with the RFC 8252 @@ -57,16 +71,151 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): A } } - return parent::validateAuthorizationRequest($request); + return ResourceAuthorizationRequest::from( + parent::validateAuthorizationRequest($request), + $this->validatedResource($request), + ); } + /** + * RFC 8707: the client may name the resource it wants the token for. An unknown + * resource is refused rather than silently ignored, so a client never believes it + * holds a narrowly-scoped token when it does not. + * + * @throws OAuthServerException + */ + private function validatedResource(ServerRequestInterface $request): string + { + $resource = $this->getQueryStringParameter('resource', $request); + if ($resource === null) { + throw OAuthServerException::invalidRequest( + 'resource', + 'The resource the token is requested for must be named (RFC 8707).' + ); + } + + if (!$this->resourceRegistry->has($resource)) { + throw OAuthServerException::invalidRequest( + 'resource', + 'The requested resource is not a known protected resource of this server.' + ); + } + + // Stamp the canonical form, not the client's spelling, so every consumer of the + // `aud` claim can compare it without canonicalising first. + return CanonicalUri::canonicalize($resource); + } + + /** + * Captures the resource so {@see self::issueAuthCode()} can record it against the + * code that is about to be issued. league builds the code payload itself and offers + * no hook for extra fields, so the binding is persisted alongside the token record + * instead of being smuggled into that payload. + */ + public function completeAuthorizationRequest( + AuthorizationRequestInterface $authorizationRequest + ): ResponseTypeInterface { + $this->pendingResource = $authorizationRequest instanceof ResourceAuthorizationRequest + ? $authorizationRequest->getResource() + : null; + + return parent::completeAuthorizationRequest($authorizationRequest); + } + + /** + * @param ScopeEntityInterface[] $scopes + * + * @throws OAuthServerException + * @throws UniqueTokenIdentifierConstraintViolationException + */ + protected function issueAuthCode( + DateInterval $authCodeTTL, + ClientEntityInterface $client, + string $userIdentifier, + ?string $redirectUri, + array $scopes = [] + ): AuthCodeEntityInterface { + $authCode = parent::issueAuthCode($authCodeTTL, $client, $userIdentifier, $redirectUri, $scopes); + + $this->tokenRecordStore->bindResource($authCode->getIdentifier(), $this->pendingResource); + + return $authCode; + } + + /** + * league validates and decrypts the authorization code inside a private method, so + * there is no hook on the decrypted payload. The token request entry point is the + * right layer to recover the binding: the code id is enough to look the resource up, + * and the record was written when the code was issued. + * + * @throws OAuthServerException + */ + public function respondToAccessTokenRequest( + ServerRequestInterface $request, + ResponseTypeInterface $responseType, + DateInterval $accessTokenTTL + ): ResponseTypeInterface { + $this->pendingResource = $this->boundResource($request); + + return parent::respondToAccessTokenRequest($request, $responseType, $accessTokenTTL); + } + + private function boundResource(ServerRequestInterface $request): ?string + { + $encryptedCode = $this->getRequestParameter('code', $request); + if (!is_string($encryptedCode)) { + return null; + } + + try { + $payload = json_decode($this->decrypt($encryptedCode), true); + } catch (Exception) { + return null; + } + + $codeId = is_array($payload) ? ($payload['auth_code_id'] ?? null) : null; + + return is_string($codeId) ? $this->tokenRecordStore->resourceFor($codeId) : null; + } + + /** + * @param ScopeEntityInterface[] $scopes + * + * @throws OAuthServerException + * @throws UniqueTokenIdentifierConstraintViolationException + */ + protected function issueAccessToken( + DateInterval $accessTokenTTL, + ClientEntityInterface $client, + ?string $userIdentifier, + array $scopes = [] + ): AccessTokenEntityInterface { + $accessToken = parent::issueAccessToken($accessTokenTTL, $client, $userIdentifier, $scopes); + + if ($accessToken instanceof AccessTokenEntity) { + $accessToken->setAudience($this->pendingResource); + } + + return $accessToken; + } + + /** + * The resource the authorization code was issued for, captured while validating + * that code so {@see self::issueAccessToken()} can stamp it onto the token. Valid + * only within a single token request. + */ + private ?string $pendingResource = null; + public function __construct( AuthCodeRepositoryInterface $authCodeRepository, RefreshTokenRepositoryInterface $refreshTokenRepository, DateInterval $authCodeTTL, private readonly bool $allowLocalhostLoopback, + private readonly ResourceRegistryInterface $resourceRegistry, + private readonly TokenRecordStoreInterface $tokenRecordStore, ) { parent::__construct($authCodeRepository, $refreshTokenRepository, $authCodeTTL); + } protected function validateRedirectUri( diff --git a/src/OAuth/Server/Grant/ResourceRefreshTokenGrant.php b/src/OAuth/Server/Grant/ResourceRefreshTokenGrant.php new file mode 100644 index 000000000..1d349badd --- /dev/null +++ b/src/OAuth/Server/Grant/ResourceRefreshTokenGrant.php @@ -0,0 +1,95 @@ +tokenRecordStore = $tokenRecordStore; + } + + /** + * @return array + * + * @throws OAuthServerException + */ + protected function validateOldRefreshToken(ServerRequestInterface $request, string $clientId): array + { + $refreshTokenData = parent::validateOldRefreshToken($request, $clientId); + + $tokenId = $refreshTokenData['refresh_token_id'] ?? null; + $this->pendingResource = is_string($tokenId) + ? $this->tokenRecordStore->resourceFor($tokenId) + : null; + + return $refreshTokenData; + } + + /** + * @param array $scopes + * + * @throws OAuthServerException + * @throws UniqueTokenIdentifierConstraintViolationException + */ + protected function issueAccessToken( + DateInterval $accessTokenTTL, + ClientEntityInterface $client, + ?string $userIdentifier, + array $scopes = [] + ): AccessTokenEntityInterface { + $accessToken = parent::issueAccessToken($accessTokenTTL, $client, $userIdentifier, $scopes); + + if ($accessToken instanceof AccessTokenEntity) { + $accessToken->setAudience($this->pendingResource); + } + + return $accessToken; + } +} diff --git a/src/OAuth/Server/Repository/RefreshTokenRepository.php b/src/OAuth/Server/Repository/RefreshTokenRepository.php index cac7947bd..13e628fa5 100644 --- a/src/OAuth/Server/Repository/RefreshTokenRepository.php +++ b/src/OAuth/Server/Repository/RefreshTokenRepository.php @@ -16,6 +16,7 @@ use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use Pimcore\Bundle\StudioBackendBundle\Entity\OAuth\OAuthTokenRecord; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Entity\AccessTokenEntity; use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Entity\RefreshTokenEntity; /** @@ -41,12 +42,16 @@ public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshToken { $accessToken = $refreshTokenEntity->getAccessToken(); + // Recording the audience here is what carries the RFC 8707 binding across a + // refresh: without it the refreshed token would come back unbound and be + // accepted at every protected resource. $this->tokenRecordStore->persist( $refreshTokenEntity->getIdentifier(), OAuthTokenRecord::TYPE_REFRESH, $refreshTokenEntity->getExpiryDateTime()->getTimestamp(), null, $accessToken->getClient()->getIdentifier(), + $accessToken instanceof AccessTokenEntity ? $accessToken->getAudience() : null, ); } diff --git a/src/OAuth/Server/Repository/ScopeRepository.php b/src/OAuth/Server/Repository/ScopeRepository.php index 7788d0029..f1249472a 100644 --- a/src/OAuth/Server/Repository/ScopeRepository.php +++ b/src/OAuth/Server/Repository/ScopeRepository.php @@ -16,19 +16,22 @@ use League\OAuth2\Server\Entities\ClientEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\ScopeRegistryInterface; use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Entity\ScopeEntity; -use function in_array; /** * @internal */ final class ScopeRepository implements ScopeRepositoryInterface { - private const array SUPPORTED = ['mcp:read', 'mcp:write']; + public function __construct( + private readonly ScopeRegistryInterface $scopeRegistry, + ) { + } public function getScopeEntityByIdentifier(string $identifier): ?ScopeEntityInterface { - return in_array($identifier, self::SUPPORTED, true) ? new ScopeEntity($identifier) : null; + return $this->scopeRegistry->has($identifier) ? new ScopeEntity($identifier) : null; } /** diff --git a/src/OAuth/Server/Repository/TokenRecordStore.php b/src/OAuth/Server/Repository/TokenRecordStore.php index d8ad9785a..4e4b28c7e 100644 --- a/src/OAuth/Server/Repository/TokenRecordStore.php +++ b/src/OAuth/Server/Repository/TokenRecordStore.php @@ -30,18 +30,41 @@ public function __construct( ) { } - public function persist(string $identifier, string $type, int $expiresAt, ?int $userId, ?string $clientId): void - { + public function persist( + string $identifier, + string $type, + int $expiresAt, + ?int $userId, + ?string $clientId, + ?string $resource = null + ): void { if ($this->find($identifier) !== null) { throw UniqueTokenIdentifierConstraintViolationException::create(); } - $this->entityManager->persist( - new OAuthTokenRecord($identifier, $type, $expiresAt, $userId, $clientId, time()) - ); + $record = new OAuthTokenRecord($identifier, $type, $expiresAt, $userId, $clientId, time()); + $record->setResource($resource); + + $this->entityManager->persist($record); + $this->entityManager->flush(); + } + + public function bindResource(string $identifier, ?string $resource): void + { + $record = $this->find($identifier); + if ($record === null) { + return; + } + + $record->setResource($resource); $this->entityManager->flush(); } + public function resourceFor(string $identifier): ?string + { + return $this->find($identifier)?->getResource(); + } + public function revoke(string $identifier): void { $record = $this->find($identifier); diff --git a/src/OAuth/Server/Repository/TokenRecordStoreInterface.php b/src/OAuth/Server/Repository/TokenRecordStoreInterface.php index a235bb372..ab7756626 100644 --- a/src/OAuth/Server/Repository/TokenRecordStoreInterface.php +++ b/src/OAuth/Server/Repository/TokenRecordStoreInterface.php @@ -28,7 +28,26 @@ interface TokenRecordStoreInterface /** * @throws UniqueTokenIdentifierConstraintViolationException on a duplicate identifier */ - public function persist(string $identifier, string $type, int $expiresAt, ?int $userId, ?string $clientId): void; + public function persist( + string $identifier, + string $type, + int $expiresAt, + ?int $userId, + ?string $clientId, + ?string $resource = null + ): void; + + /** + * Records the protected resource a token is bound to. The record already exists by + * the time the binding is known, because league persists the token as it issues it. + */ + public function bindResource(string $identifier, ?string $resource): void; + + /** + * The protected resource a previously persisted token was bound to, or null when it + * was unbound or is unknown. + */ + public function resourceFor(string $identifier): ?string; public function revoke(string $identifier): void; diff --git a/src/OAuth/Server/RequestType/ResourceAuthorizationRequest.php b/src/OAuth/Server/RequestType/ResourceAuthorizationRequest.php new file mode 100644 index 000000000..3bdc64435 --- /dev/null +++ b/src/OAuth/Server/RequestType/ResourceAuthorizationRequest.php @@ -0,0 +1,62 @@ +setGrantTypeId($request->getGrantTypeId()); + $copy->setClient($request->getClient()); + $copy->setRedirectUri($request->getRedirectUri()); + $copy->setScopes($request->getScopes()); + $copy->setState($request->getState()); + $copy->setCodeChallenge($request->getCodeChallenge()); + $copy->setCodeChallengeMethod($request->getCodeChallengeMethod()); + $copy->setAuthorizationApproved($request->isAuthorizationApproved()); + + $user = $request->getUser(); + if ($user !== null) { + $copy->setUser($user); + } + + $copy->resource = $resource; + + return $copy; + } + + public function getResource(): ?string + { + return $this->resource; + } +} diff --git a/src/OAuth/Token/EmbeddedTokenValidator.php b/src/OAuth/Token/EmbeddedTokenValidator.php index 280314b83..3b8013f8c 100644 --- a/src/OAuth/Token/EmbeddedTokenValidator.php +++ b/src/OAuth/Token/EmbeddedTokenValidator.php @@ -13,6 +13,8 @@ namespace Pimcore\Bundle\StudioBackendBundle\OAuth\Token; +use const PREG_SPLIT_NO_EMPTY; +use Exception; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; @@ -25,7 +27,9 @@ use Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\TokenRevocationCheckerInterface; use Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\TokenValidatorInterface; use Pimcore\Bundle\StudioBackendBundle\OAuth\Dto\ResolvedAccess; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Util\CanonicalUri; use Psr\Clock\ClockInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\Clock\NativeClock; use Throwable; use function array_filter; @@ -35,7 +39,6 @@ use function preg_split; use function str_contains; use function trim; -use const PREG_SPLIT_NO_EMPTY; /** * Validates a JWT access token minted by the embedded authorization server: @@ -60,6 +63,7 @@ public function __construct( private readonly IdentityResolverInterface $identityResolver, private readonly TokenRevocationCheckerInterface $revocationChecker, ?ClockInterface $clock = null, + private readonly ?LoggerInterface $logger = null, ) { $this->clock = $clock ?? new NativeClock(); } @@ -89,9 +93,13 @@ public function validate(string $rawToken, string $resourceUri): ?ResolvedAccess return null; } - // Audience (resource) binding per RFC 8707 is captured here but not yet - // enforced; $resourceUri is the seam where that check is added (#1309/#1310). + // Audience (resource) binding per RFC 8707. A token minted for one resource must + // not be accepted at another, which is what keeps a token obtained for one + // application from opening every other protected resource of this server. $audience = $this->toStringList($claims->get('aud', [])); + if (!$this->permittedFor($audience, $resourceUri)) { + return null; + } $subject = $claims->get('sub'); if (!is_string($subject)) { @@ -113,6 +121,30 @@ public function validate(string $rawToken, string $resourceUri): ?ResolvedAccess ); } + /** + * A token with no audience predates audience binding, or was issued for a request + * that named no resource. Those are accepted so that enabling this does not + * invalidate tokens already in circulation; a token that DOES name an audience is + * held to it. + * + * @param list $audience + */ + private function permittedFor(array $audience, string $resourceUri): bool + { + if ($audience === []) { + return true; + } + + $canonical = CanonicalUri::canonicalize($resourceUri); + foreach ($audience as $entry) { + if (CanonicalUri::canonicalize($entry) === $canonical) { + return true; + } + } + + return false; + } + private function parseVerifiedToken(Configuration $configuration, string $rawToken): ?UnencryptedToken { try { @@ -143,9 +175,18 @@ private function configuration(): ?Configuration return null; } - $key = str_contains($this->publicKey, 'BEGIN') - ? InMemory::plainText($this->publicKey) - : InMemory::file($this->publicKey); + // Misconfigured or unreadable key material must fail closed. Letting the + // exception escape would turn every request at every protected resource into a + // 500 instead of an authentication failure. + try { + $key = str_contains($this->publicKey, 'BEGIN') + ? InMemory::plainText($this->publicKey) + : InMemory::file($this->publicKey); + } catch (Exception $exception) { + $this->logger?->error('OAuth public key could not be read', ['error' => $exception->getMessage()]); + + return null; + } // Verification only: the signer/verification key are used to check the // signature; the same public key fills the (unused) signing-key slot. diff --git a/src/OpenApi/Config/Tags.php b/src/OpenApi/Config/Tags.php index 3436f71ff..118fdb79f 100644 --- a/src/OpenApi/Config/Tags.php +++ b/src/OpenApi/Config/Tags.php @@ -38,6 +38,10 @@ name: Tags::Cache->value, description: 'tag_cache_description', )] +#[Tag( + name: Tags::Mcp->value, + description: 'tag_mcp_description', +)] #[Tag( name: Tags::ClassDefinition->value, description: 'tag_class_description' @@ -210,6 +214,7 @@ enum Tags: string case RecycleBin = 'Recycle Bin'; case Role = 'Role Management'; case Search = 'Search'; + case Mcp = 'MCP'; case Schedule = 'Schedule'; case Settings = 'Settings'; case SettingsAdmin = 'Settings Admin'; diff --git a/src/PimcoreStudioBackendBundle.php b/src/PimcoreStudioBackendBundle.php index 607e37ab2..773736025 100644 --- a/src/PimcoreStudioBackendBundle.php +++ b/src/PimcoreStudioBackendBundle.php @@ -25,8 +25,10 @@ use Pimcore\Bundle\StudioBackendBundle\DependencyInjection\CompilerPass\GridColumnDefinitionPass; use Pimcore\Bundle\StudioBackendBundle\DependencyInjection\CompilerPass\GridColumnResolverPass; use Pimcore\Bundle\StudioBackendBundle\DependencyInjection\CompilerPass\ListingFilterPass; +use Pimcore\Bundle\StudioBackendBundle\DependencyInjection\CompilerPass\McpToolPass; use Pimcore\Bundle\StudioBackendBundle\DependencyInjection\CompilerPass\MercureTopicsProviderPass; use Pimcore\Bundle\StudioBackendBundle\DependencyInjection\CompilerPass\NotificationDispatchPass; +use Pimcore\Bundle\StudioBackendBundle\DependencyInjection\CompilerPass\OAuthScopeProviderPass; use Pimcore\Bundle\StudioBackendBundle\DependencyInjection\CompilerPass\PatchAdapterPass; use Pimcore\Bundle\StudioBackendBundle\DependencyInjection\CompilerPass\PhpCodeTransformerPass; use Pimcore\Bundle\StudioBackendBundle\DependencyInjection\CompilerPass\SettingsProviderPass; @@ -99,6 +101,13 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new DataProviderPass()); $container->addCompilerPass(new DocumentTypeAdapterPass()); $container->addCompilerPass(new NotificationDispatchPass()); + + // MCP tools are SDK-native #[McpTool] services, opted in with the + // McpToolRegistry::TAG tag; the pass reflects them into the registry. + $container->addCompilerPass(new McpToolPass()); + + // OAuth scopes are contributed by any bundle exposing protected resources. + $container->addCompilerPass(new OAuthScopeProviderPass()); } public static function registerDependentBundles(BundleCollection $collection): void diff --git a/src/Security/Authenticator/Mcp/OAuthAccessTokenAuthenticator.php b/src/Security/Authenticator/Mcp/OAuthAccessTokenAuthenticator.php index feb229723..09e46b1e0 100644 --- a/src/Security/Authenticator/Mcp/OAuthAccessTokenAuthenticator.php +++ b/src/Security/Authenticator/Mcp/OAuthAccessTokenAuthenticator.php @@ -26,7 +26,9 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; +use function is_string; use function preg_match; +use function rtrim; use function str_starts_with; use function strlen; use function substr; @@ -52,6 +54,7 @@ final class OAuthAccessTokenAuthenticator extends AbstractAuthenticator public function __construct( private readonly bool $enabled, private readonly TokenValidatorInterface $tokenValidator, + private readonly ?string $issuer = null, ) { } @@ -122,8 +125,25 @@ private function bearerToken(Request $request): ?string return $token === '' ? null : $token; } + /** + * Each MCP server is its own protected resource, registered as + * `/pimcore-mcp/studio/`, and that is what a client discovering this + * endpoint asks a token for. The URI has to be derived the same way here or an + * audience-bound token would be refused at the very endpoint it was issued for. + * + * The slug comes from the matched route, which is available because the router runs + * before the firewall. Without one (an unrouted request) the prefix stands in, which + * only an audience-less token can satisfy. + */ private function resourceUri(Request $request): string { - return CanonicalUri::canonicalize($request->getSchemeAndHttpHost() . '/pimcore-mcp'); + $base = rtrim($this->issuer ?? $request->getSchemeAndHttpHost(), '/'); + $server = $request->attributes->get('server'); + + if (!is_string($server) || $server === '') { + return CanonicalUri::canonicalize($base . '/pimcore-mcp'); + } + + return CanonicalUri::canonicalize($base . '/pimcore-mcp/studio/' . $server); } } diff --git a/src/Util/Constant/UserPermissions.php b/src/Util/Constant/UserPermissions.php index 24d7f1ef6..7cca7dbf4 100644 --- a/src/Util/Constant/UserPermissions.php +++ b/src/Util/Constant/UserPermissions.php @@ -30,6 +30,7 @@ enum UserPermissions: string case EMAILS = 'emails'; case FIELD_COLLECTIONS = 'fieldcollections'; case GDPR = 'gdpr_data_extractor'; + case MCP_SERVERS = 'mcp_servers'; case NOTES_EVENTS = 'notes_events'; case NOTIFICATIONS = 'notifications'; case NOTIFICATIONS_SEND = 'notifications_send'; diff --git a/tests/Unit/DependencyInjection/McpServersConfigurationTest.php b/tests/Unit/DependencyInjection/McpServersConfigurationTest.php new file mode 100644 index 000000000..ba3961d1a --- /dev/null +++ b/tests/Unit/DependencyInjection/McpServersConfigurationTest.php @@ -0,0 +1,111 @@ + $servers + * + * @return array + */ + private function process(array $servers): array + { + $root = (new TreeBuilder('pimcore_studio_backend'))->getRootNode(); + (new ReflectionMethod(Configuration::class, 'addMcpServersConfigurationNode')) + ->invoke(new Configuration(), $root); + + return (new Processor())->process( + $root->getNode(true), + [[Configuration::MCP_SERVERS_NODE => $servers]] + ); + } + + public function testOwnerIsAUsernameString(): void + { + $processed = $this->process([ + 'cars' => ['access' => ['owner' => 'john.doe']], + ]); + + $this->assertSame('john.doe', $processed[Configuration::MCP_SERVERS_NODE]['cars']['access']['owner']); + } + + public function testSharedUsersAndRolesAcceptTheCapabilityGrid(): void + { + $processed = $this->process([ + 'cars' => [ + 'access' => [ + 'owner' => 'john.doe', + 'share_global' => true, + 'shared_users' => [ + ['name' => 'alice', 'can_access' => true, 'can_edit' => false], + 'bob', + ], + 'shared_roles' => [ + ['name' => 'editors', 'can_edit' => true], + ], + ], + ], + ]); + + $access = $processed[Configuration::MCP_SERVERS_NODE]['cars']['access']; + + $this->assertSame('john.doe', $access['owner']); + $this->assertTrue($access['share_global']); + // assertEquals, not assertSame: Symfony appends defaulted keys after the + // provided ones, so the associative key order is not fixed (list order is). + $this->assertEquals([ + ['name' => 'alice', 'can_access' => true, 'can_edit' => false], + ['name' => 'bob', 'can_access' => false, 'can_edit' => false], + ], $access['shared_users']); + $this->assertEquals([ + ['name' => 'editors', 'can_access' => false, 'can_edit' => true], + ], $access['shared_roles']); + } + + public function testProcessedAccessRoundTripsThroughMcpServerAccess(): void + { + $processed = $this->process([ + 'cars' => [ + 'access' => [ + 'owner' => 'john.doe', + 'shared_users' => [['name' => 'alice', 'can_access' => true, 'can_edit' => true]], + ], + ], + ]); + + $access = McpServerAccess::fromArray($processed[Configuration::MCP_SERVERS_NODE]['cars']['access']); + + $this->assertSame('john.doe', $access->owner); + $this->assertEquals( + [new McpServerAccessEntry('alice', canAccess: true, canEdit: true)], + $access->sharedUsers + ); + } +} diff --git a/tests/Unit/Mcp/Dto/McpServerAccessEntryTest.php b/tests/Unit/Mcp/Dto/McpServerAccessEntryTest.php new file mode 100644 index 000000000..c6a360bda --- /dev/null +++ b/tests/Unit/Mcp/Dto/McpServerAccessEntryTest.php @@ -0,0 +1,72 @@ + 'john.doe', 'canAccess' => true, 'canEdit' => false]); + + $this->assertNotNull($entry); + $this->assertSame('john.doe', $entry->name); + $this->assertTrue($entry->canAccess); + $this->assertFalse($entry->canEdit); + } + + public function testFromMixedReadsSnakeCaseFromStorage(): void + { + $entry = McpServerAccessEntry::fromMixed(['name' => 'a', 'can_access' => true, 'can_edit' => true]); + + $this->assertTrue($entry->canAccess); + $this->assertTrue($entry->canEdit); + } + + public function testFromMixedTreatsBareStringAsViewOnly(): void + { + $entry = McpServerAccessEntry::fromMixed('editors'); + + $this->assertNotNull($entry); + $this->assertSame('editors', $entry->name); + $this->assertFalse($entry->canAccess); + $this->assertFalse($entry->canEdit); + } + + public function testFromMixedDefaultsMissingCapabilitiesToFalse(): void + { + $entry = McpServerAccessEntry::fromMixed(['name' => 'a']); + + $this->assertFalse($entry->canAccess); + $this->assertFalse($entry->canEdit); + } + + public function testFromMixedReturnsNullWithoutUsableName(): void + { + $this->assertNull(McpServerAccessEntry::fromMixed(['canAccess' => true])); + $this->assertNull(McpServerAccessEntry::fromMixed(['name' => ''])); + $this->assertNull(McpServerAccessEntry::fromMixed(['name' => 5])); + $this->assertNull(McpServerAccessEntry::fromMixed(null)); + } + + public function testToArrayUsesSnakeCase(): void + { + $this->assertSame( + ['name' => 'admins', 'can_access' => true, 'can_edit' => false], + (new McpServerAccessEntry('admins', canAccess: true, canEdit: false))->toArray() + ); + } +} diff --git a/tests/Unit/Mcp/Dto/McpServerAccessTest.php b/tests/Unit/Mcp/Dto/McpServerAccessTest.php new file mode 100644 index 000000000..0ab0a946d --- /dev/null +++ b/tests/Unit/Mcp/Dto/McpServerAccessTest.php @@ -0,0 +1,96 @@ +assertNull($access->owner); + $this->assertFalse($access->shareGlobal); + $this->assertSame([], $access->sharedUsers); + $this->assertSame([], $access->sharedRoles); + } + + public function testGridArrayWithCapabilities(): void + { + $access = McpServerAccess::fromArray([ + 'owner' => 'john.doe', + 'share_global' => true, + 'shared_users' => [ + ['name' => 'alice', 'can_access' => true, 'can_edit' => true], + ['name' => 'bob', 'can_access' => true, 'can_edit' => false], + ], + 'shared_roles' => [['name' => 'editors', 'can_access' => false, 'can_edit' => true]], + ]); + + $this->assertSame('john.doe', $access->owner); + $this->assertTrue($access->shareGlobal); + $this->assertEquals( + [ + new McpServerAccessEntry('alice', canAccess: true, canEdit: true), + new McpServerAccessEntry('bob', canAccess: true, canEdit: false), + ], + $access->sharedUsers + ); + $this->assertEquals([new McpServerAccessEntry('editors', canAccess: false, canEdit: true)], $access->sharedRoles); + } + + public function testReadsBareStringNamesAsViewOnly(): void + { + $access = McpServerAccess::fromArray(['shared_users' => ['alice', 'bob']]); + + $this->assertEquals( + [new McpServerAccessEntry('alice'), new McpServerAccessEntry('bob')], + $access->sharedUsers + ); + } + + public function testDropsInvalidEntriesAndNonArrayLists(): void + { + $access = McpServerAccess::fromArray([ + 'shared_users' => [['can_access' => true], ['name' => ''], null, ['name' => 'carol']], + 'shared_roles' => 'not-an-array', + ]); + + $this->assertEquals([new McpServerAccessEntry('carol')], $access->sharedUsers); + $this->assertSame([], $access->sharedRoles); + } + + public function testRoundTrip(): void + { + $access = new McpServerAccess( + owner: 'john.doe', + shareGlobal: true, + sharedUsers: [new McpServerAccessEntry('alice', canAccess: true, canEdit: true)], + sharedRoles: [new McpServerAccessEntry('editors', canAccess: true, canEdit: false)], + ); + + $this->assertEquals($access, McpServerAccess::fromArray($access->toArray())); + } + + public function testToArrayKeysAndEntryShape(): void + { + $array = (new McpServerAccess(sharedUsers: [new McpServerAccessEntry('alice', canAccess: true, canEdit: false)]))->toArray(); + + $this->assertSame(['owner', 'share_global', 'shared_users', 'shared_roles'], array_keys($array)); + $this->assertSame([['name' => 'alice', 'can_access' => true, 'can_edit' => false]], $array['shared_users']); + } +} diff --git a/tests/Unit/Mcp/Dto/McpServerDefinitionTest.php b/tests/Unit/Mcp/Dto/McpServerDefinitionTest.php new file mode 100644 index 000000000..123aa03ae --- /dev/null +++ b/tests/Unit/Mcp/Dto/McpServerDefinitionTest.php @@ -0,0 +1,107 @@ + 'Data Objects (read)', + 'description' => 'Read-only data object tools', + 'url_slug' => 'data-objects-read', + 'tools' => ['get_data_object', 'search_data_objects'], + 'scopes' => ['mcp:read'], + 'enabled' => false, + 'access' => ['owner' => 'jane.doe', 'share_global' => true], + ]); + + $this->assertSame('objects-read', $server->id); + $this->assertSame('Data Objects (read)', $server->displayName); + $this->assertSame('Read-only data object tools', $server->description); + $this->assertSame('data-objects-read', $server->urlSlug); + $this->assertSame(['get_data_object', 'search_data_objects'], $server->toolIds); + $this->assertSame(['mcp:read'], $server->scopes); + $this->assertFalse($server->enabled); + $this->assertSame('jane.doe', $server->access->owner); + $this->assertTrue($server->access->shareGlobal); + } + + public function testDefaultsFallBackToId(): void + { + $server = McpServerDefinition::fromArray('assets', []); + + $this->assertSame('assets', $server->id); + $this->assertSame('assets', $server->displayName, 'name defaults to the id'); + $this->assertSame('', $server->description); + $this->assertSame('assets', $server->urlSlug, 'url_slug defaults to the id'); + $this->assertSame([], $server->toolIds); + $this->assertSame([], $server->scopes); + $this->assertTrue($server->enabled, 'enabled defaults to true'); + $this->assertEquals(new McpServerAccess(), $server->access); + } + + public function testBlankUrlSlugFallsBackToId(): void + { + $server = McpServerDefinition::fromArray('tags', ['url_slug' => '']); + + $this->assertSame('tags', $server->urlSlug); + } + + public function testNonStringToolsAndScopesAreDropped(): void + { + $server = McpServerDefinition::fromArray('mixed', [ + 'tools' => ['ok', 5, null, 'fine'], + 'scopes' => ['mcp:read', ['nested']], + ]); + + $this->assertSame(['ok', 'fine'], $server->toolIds); + $this->assertSame(['mcp:read'], $server->scopes); + } + + public function testRoundTrip(): void + { + $server = new McpServerDefinition( + id: 'workflows', + displayName: 'Workflows', + description: 'Workflow tools', + urlSlug: 'workflows', + toolIds: ['apply_transition'], + scopes: ['mcp:read', 'mcp:write'], + enabled: true, + access: new McpServerAccess( + owner: 'jane.doe', + sharedRoles: [new McpServerAccessEntry('editors', canAccess: true)] + ), + ); + + $this->assertEquals($server, McpServerDefinition::fromArray($server->id, $server->toArray())); + } + + public function testToArrayOmitsIdAndKeepsStorageKeys(): void + { + $array = McpServerDefinition::fromArray('x', ['name' => 'X'])->toArray(); + + $this->assertArrayNotHasKey('id', $array); + $this->assertSame( + ['name', 'description', 'url_slug', 'tools', 'scopes', 'enabled', 'access'], + array_keys($array) + ); + } +} diff --git a/tests/Unit/Mcp/Hydrator/McpServerHydratorTest.php b/tests/Unit/Mcp/Hydrator/McpServerHydratorTest.php new file mode 100644 index 000000000..1ad7ffce2 --- /dev/null +++ b/tests/Unit/Mcp/Hydrator/McpServerHydratorTest.php @@ -0,0 +1,104 @@ +hydrate( + $definition, + 'https://host/pimcore-mcp/studio/product-read', + ['mcp:read'], + true, + new McpServerUserPermissions(canView: true, canAccess: false, canEdit: true), + ); + + $this->assertSame('product-read', $server->getId()); + $this->assertSame('john.doe', $server->getOwner()); + $this->assertFalse($server->isShareGlobal()); + $this->assertTrue($server->isWriteable()); + $this->assertSame(2, $server->getToolCount()); + + $users = $server->getSharedUsers(); + $this->assertCount(1, $users); + $this->assertSame('alice', $users[0]->getName()); + $this->assertTrue($users[0]->isCanAccess()); + $this->assertTrue($users[0]->isCanEdit()); + + $roles = $server->getSharedRoles(); + $this->assertSame('editors', $roles[0]->getName()); + $this->assertFalse($roles[0]->isCanAccess()); + $this->assertTrue($roles[0]->isCanEdit()); + + $this->assertTrue($server->getCurrentUserPermissions()->isCanView()); + $this->assertFalse($server->getCurrentUserPermissions()->isCanAccess()); + $this->assertTrue($server->getCurrentUserPermissions()->isCanEdit()); + } + + public function testHydrateNormalisesEmptyDescriptionAndEmptyGrid(): void + { + $definition = new McpServerDefinition( + id: 'x', + displayName: 'X', + description: '', + urlSlug: 'x', + toolIds: [], + scopes: [], + enabled: false, + access: new McpServerAccess(), + ); + + $server = (new McpServerHydrator())->hydrate( + $definition, + 'https://host/pimcore-mcp/studio/x', + [], + false, + new McpServerUserPermissions(canView: false, canAccess: false, canEdit: false), + ); + + $this->assertNull($server->getDescription()); + $this->assertNull($server->getOwner()); + $this->assertSame([], $server->getSharedUsers()); + $this->assertSame([], $server->getSharedRoles()); + $this->assertFalse($server->getCurrentUserPermissions()->isCanView()); + $this->assertSame(0, $server->getToolCount()); + } +} diff --git a/tests/Unit/Mcp/Hydrator/McpToolItemHydratorTest.php b/tests/Unit/Mcp/Hydrator/McpToolItemHydratorTest.php new file mode 100644 index 000000000..6e6fa1642 --- /dev/null +++ b/tests/Unit/Mcp/Hydrator/McpToolItemHydratorTest.php @@ -0,0 +1,86 @@ +hydrate($this->reference('get_car_info', 'Get Car Info', readOnly: true)); + + $this->assertSame('get_car_info', $item->getName()); + $this->assertSame('Get Car Info', $item->getTitle()); + $this->assertSame('Get Car Info tool', $item->getDescription()); + $this->assertSame('mcp:read', $item->getRequiredScope()); + $this->assertTrue($item->isReadOnly()); + $this->assertFalse($item->isDestructive()); + } + + public function testHydrateDerivesWriteScopeAndDestructiveHint(): void + { + $reference = new McpToolReference( + name: 'delete_object', + title: 'Delete Object', + description: 'Deletes a data object', + annotations: new ToolAnnotations(readOnlyHint: false, destructiveHint: true), + outputSchema: null, + className: stdClass::class, + method: 'execute', + ); + + $item = (new McpToolItemHydrator())->hydrate($reference); + + $this->assertSame('mcp:write', $item->getRequiredScope()); + $this->assertFalse($item->isReadOnly()); + $this->assertTrue($item->isDestructive()); + } + + public function testHydrateFallsBackToNameWhenTitleIsNull(): void + { + $item = (new McpToolItemHydrator())->hydrate(new McpToolReference( + name: 'ping', + title: null, + description: '', + annotations: null, + outputSchema: null, + className: stdClass::class, + method: 'execute', + )); + + $this->assertSame('ping', $item->getTitle()); + $this->assertSame('mcp:write', $item->getRequiredScope()); + } + + private function reference(string $name, string $title, bool $readOnly): McpToolReference + { + return new McpToolReference( + name: $name, + title: $title, + description: $title . ' tool', + annotations: new ToolAnnotations(readOnlyHint: $readOnly), + outputSchema: null, + className: stdClass::class, + method: 'execute', + ); + } +} diff --git a/tests/Unit/Mcp/Registry/McpToolRegistryTest.php b/tests/Unit/Mcp/Registry/McpToolRegistryTest.php new file mode 100644 index 000000000..6bc74840a --- /dev/null +++ b/tests/Unit/Mcp/Registry/McpToolRegistryTest.php @@ -0,0 +1,80 @@ + [ + 'class' => stdClass::class, 'method' => 'execute', 'title' => 'Get Thing', + 'description' => 'reads a thing', 'annotations' => ['readOnlyHint' => true], 'outputSchema' => null, + ], + 'delete_thing' => [ + 'class' => stdClass::class, 'method' => 'run', 'title' => null, + 'description' => '', 'annotations' => null, 'outputSchema' => null, + ], + ]); + + $this->assertSame(['get_thing', 'delete_thing'], $registry->names()); + $this->assertTrue($registry->has('get_thing')); + $this->assertFalse($registry->has('missing')); + $this->assertNull($registry->get('missing')); + $this->assertCount(2, $registry->all()); + + $ref = $registry->get('get_thing'); + $this->assertInstanceOf(McpToolReference::class, $ref); + $this->assertSame('get_thing', $ref->name); + $this->assertSame('Get Thing', $ref->title); + $this->assertSame(stdClass::class, $ref->className); + $this->assertSame('execute', $ref->method); + $this->assertInstanceOf(ToolAnnotations::class, $ref->annotations); + $this->assertTrue($ref->isReadOnly()); + $this->assertFalse($ref->isDestructive()); + + $writeRef = $registry->get('delete_thing'); + $this->assertNull($writeRef->title); + $this->assertNull($writeRef->annotations); + $this->assertFalse($writeRef->isReadOnly()); + } + + public function testEmptyRegistry(): void + { + $registry = new McpToolRegistry(); + + $this->assertSame([], $registry->all()); + $this->assertSame([], $registry->names()); + } + + public function testGetLocatorFallsBackToAnEmptyContainer(): void + { + $this->assertInstanceOf(ContainerInterface::class, (new McpToolRegistry())->getLocator()); + } + + public function testGetLocatorReturnsTheInjectedOne(): void + { + $locator = new ServiceLocator([]); + + $this->assertSame($locator, (new McpToolRegistry([], $locator))->getLocator()); + } +} diff --git a/tests/Unit/Mcp/Repository/McpServerConfigRepositoryTest.php b/tests/Unit/Mcp/Repository/McpServerConfigRepositoryTest.php new file mode 100644 index 000000000..8103f8bac --- /dev/null +++ b/tests/Unit/Mcp/Repository/McpServerConfigRepositoryTest.php @@ -0,0 +1,178 @@ +> $seed + */ + private function inMemoryStore(array $seed = [], bool $writeable = true): LocationAwareConfigRepository + { + return new class($seed, $writeable) extends LocationAwareConfigRepository { + /** @param array> $data */ + public function __construct(public array $data, private readonly bool $writeable) + { + // Intentionally does not call parent::__construct — every method + // the repository touches is overridden below. + } + + public function loadConfigByKey(string $key): array + { + return [$this->data[$key] ?? null, 'settings-store']; + } + + public function fetchAllKeys(): array + { + return array_keys($this->data); + } + + public function saveConfig(string $key, mixed $data, ?callable $yamlStructureCallback = null): void + { + $this->data[$key] = $data; + } + + public function deleteData(string $key, ?string $dataSource): void + { + unset($this->data[$key]); + } + + public function getWriteTarget(): string + { + return 'settings-store'; + } + + public function isWriteable(?string $key = null, ?string $dataSource = null): bool + { + return $this->writeable; + } + }; + } + + private function repository(LocationAwareConfigRepository $store): McpServerConfigRepository + { + return new McpServerConfigRepository([], [], $store); + } + + public function testGetReadsAndMapsAServer(): void + { + $store = $this->inMemoryStore([ + 'objects-read' => ['name' => 'Objects', 'tools' => ['get_data_object'], 'scopes' => ['mcp:read']], + ]); + + $server = $this->repository($store)->get('objects-read'); + + $this->assertSame('objects-read', $server->id); + $this->assertSame('Objects', $server->displayName); + $this->assertSame(['get_data_object'], $server->toolIds); + } + + public function testGetThrowsNotFoundForUnknownId(): void + { + $this->expectException(NotFoundException::class); + + $this->repository($this->inMemoryStore())->get('missing'); + } + + public function testHasReflectsPresence(): void + { + $repository = $this->repository($this->inMemoryStore(['a' => ['name' => 'A']])); + + $this->assertTrue($repository->has('a')); + $this->assertFalse($repository->has('b')); + } + + public function testListMapsAllServers(): void + { + $store = $this->inMemoryStore([ + 'a' => ['name' => 'A'], + 'b' => ['name' => 'B'], + ]); + + $servers = $this->repository($store)->list(); + + $this->assertCount(2, $servers); + $this->assertSame(['a', 'b'], array_map(static fn (McpServerDefinition $s): string => $s->id, $servers)); + } + + public function testSaveThenGetRoundTrip(): void + { + $store = $this->inMemoryStore(); + $repository = $this->repository($store); + + $server = new McpServerDefinition( + id: 'assets-read', + displayName: 'Assets (read)', + description: 'Read-only asset tools', + urlSlug: 'assets-read', + toolIds: ['get_asset'], + scopes: ['mcp:read'], + enabled: true, + access: new McpServerAccess( + owner: 'jane.doe', + sharedRoles: [new McpServerAccessEntry('editors', canAccess: true, canEdit: true)] + ), + ); + $repository->save($server); + + $this->assertEquals($server, $repository->get('assets-read')); + } + + public function testSaveThrowsWhenStorageIsReadOnly(): void + { + $repository = $this->repository($this->inMemoryStore(writeable: false)); + + $this->expectException(NotWriteableException::class); + + $repository->save(new McpServerDefinition( + id: 'x', + displayName: 'X', + description: '', + urlSlug: 'x', + toolIds: [], + scopes: [], + enabled: true, + access: new McpServerAccess(), + )); + } + + public function testDeleteRemovesAndThenReportsAbsent(): void + { + $store = $this->inMemoryStore(['gone' => ['name' => 'Gone']]); + $repository = $this->repository($store); + + $repository->delete('gone'); + + $this->assertFalse($repository->has('gone')); + } + + public function testDeleteThrowsNotFoundForUnknownId(): void + { + $this->expectException(NotFoundException::class); + + $this->repository($this->inMemoryStore())->delete('missing'); + } +} diff --git a/tests/Unit/Mcp/Security/McpServerAccessResolverTest.php b/tests/Unit/Mcp/Security/McpServerAccessResolverTest.php new file mode 100644 index 000000000..a06983133 --- /dev/null +++ b/tests/Unit/Mcp/Security/McpServerAccessResolverTest.php @@ -0,0 +1,166 @@ +server(new McpServerAccess()); + + // Admins manage everything but must be granted Access explicitly. + $this->assertSame(['view' => true, 'access' => false, 'edit' => true], $this->resolver()->resolve($server, $this->user(isAdmin: true))); + } + + public function testAdminGetsAccessWhenExplicitlyGranted(): void + { + $server = $this->server(new McpServerAccess(sharedUsers: [$this->entry(self::USER_NAME, canAccess: true)])); + + $this->assertSame(['view' => true, 'access' => true, 'edit' => true], $this->resolver()->resolve($server, $this->user(isAdmin: true))); + } + + public function testOwnerHasViewAndEditButNotAccess(): void + { + // The owner is symmetric with an admin: implicit read + edit, explicit access. + $server = $this->server(new McpServerAccess(owner: self::USER_NAME)); + + $this->assertSame(['view' => true, 'access' => false, 'edit' => true], $this->resolver()->resolve($server, $this->user())); + } + + public function testOwnerGetsAccessWhenExplicitlyListed(): void + { + $server = $this->server(new McpServerAccess( + owner: self::USER_NAME, + sharedUsers: [$this->entry(self::USER_NAME, canAccess: true)], + )); + + $this->assertSame(['view' => true, 'access' => true, 'edit' => true], $this->resolver()->resolve($server, $this->user())); + } + + public function testPublicGrantsViewAndAccessButNotEdit(): void + { + $server = $this->server(new McpServerAccess(shareGlobal: true)); + + $this->assertSame(['view' => true, 'access' => true, 'edit' => false], $this->resolver()->resolve($server, $this->user())); + } + + public function testListedWithNoCapabilitiesIsViewOnly(): void + { + $server = $this->server(new McpServerAccess(sharedUsers: [$this->entry(self::USER_NAME)])); + + $this->assertSame(['view' => true, 'access' => false, 'edit' => false], $this->resolver()->resolve($server, $this->user())); + } + + public function testCanAccessGrantsAccessNotEdit(): void + { + $server = $this->server(new McpServerAccess(sharedUsers: [$this->entry(self::USER_NAME, canAccess: true)])); + + $this->assertSame(['view' => true, 'access' => true, 'edit' => false], $this->resolver()->resolve($server, $this->user())); + } + + public function testCanEditGrantsEditNotAccess(): void + { + $server = $this->server(new McpServerAccess(sharedUsers: [$this->entry(self::USER_NAME, canEdit: true)])); + + $this->assertSame(['view' => true, 'access' => false, 'edit' => true], $this->resolver()->resolve($server, $this->user())); + } + + public function testRoleGrantApplies(): void + { + $server = $this->server(new McpServerAccess(sharedRoles: [$this->entry('editors', canAccess: true, canEdit: true)])); + $resolver = $this->resolver([9 => 'editors']); + + $this->assertSame(['view' => true, 'access' => true, 'edit' => true], $resolver->resolve($server, $this->user(roles: [3, 9]))); + } + + public function testCapabilitiesAreUnionOfUserAndRoleEntries(): void + { + // User entry grants only access; a role entry grants only edit — the user gets both. + $server = $this->server(new McpServerAccess( + sharedUsers: [$this->entry(self::USER_NAME, canAccess: true)], + sharedRoles: [$this->entry('editors', canEdit: true)], + )); + $resolver = $this->resolver([9 => 'editors']); + + $this->assertSame(['view' => true, 'access' => true, 'edit' => true], $resolver->resolve($server, $this->user(roles: [9]))); + } + + public function testUnrelatedUserIsDeniedEverything(): void + { + $server = $this->server(new McpServerAccess( + owner: 'someone.else', + sharedUsers: [$this->entry('alice', canAccess: true, canEdit: true)], + sharedRoles: [$this->entry('editors', canEdit: true)], + )); + $resolver = $this->resolver([4 => 'viewers', 5 => 'guests']); + + $this->assertSame(['view' => false, 'access' => false, 'edit' => false], $resolver->resolve($server, $this->user(roles: [4, 5]))); + } + + private function entry(string $name, bool $canAccess = false, bool $canEdit = false): McpServerAccessEntry + { + return new McpServerAccessEntry($name, $canAccess, $canEdit); + } + + private function server(McpServerAccess $access): McpServerDefinition + { + return new McpServerDefinition('s', 'S', '', 's', ['ping'], ['mcp:read'], true, $access); + } + + /** + * @param array $roleIdToName role id → role name the resolver should return + */ + private function resolver(array $roleIdToName = []): McpServerAccessResolver + { + $roleResolver = $this->createMock(RoleResolverInterface::class); + $roleResolver->method('getById')->willReturnCallback( + function (int $id) use ($roleIdToName): ?Role { + if (!isset($roleIdToName[$id])) { + return null; + } + + $role = $this->createMock(Role::class); + $role->method('getName')->willReturn($roleIdToName[$id]); + + return $role; + } + ); + + return new McpServerAccessResolver($roleResolver); + } + + /** + * @param list $roles + */ + private function user(bool $isAdmin = false, array $roles = []): UserInterface + { + $user = $this->createMock(UserInterface::class); + $user->method('isAdmin')->willReturn($isAdmin); + $user->method('getName')->willReturn(self::USER_NAME); + $user->method('getRoles')->willReturn($roles); + + return $user; + } +} diff --git a/tests/Unit/Mcp/Server/McpServerFactoryTest.php b/tests/Unit/Mcp/Server/McpServerFactoryTest.php new file mode 100644 index 000000000..e882aa9c1 --- /dev/null +++ b/tests/Unit/Mcp/Server/McpServerFactoryTest.php @@ -0,0 +1,94 @@ +factory()->createServer($this->definition(['ping'])); + + $this->assertInstanceOf(Server::class, $server); + } + + public function testCachesTheServerPerDefinitionId(): void + { + $factory = $this->factory(); + $definition = $this->definition(['ping']); + + $this->assertSame($factory->createServer($definition), $factory->createServer($definition)); + } + + public function testUnknownToolIsSkippedNotFatal(): void + { + // 'nope' resolves to null in the registry; the server still builds. + $server = $this->factory()->createServer($this->definition(['ping', 'nope'])); + + $this->assertInstanceOf(Server::class, $server); + } + + private function factory(): McpServerFactory + { + $registry = $this->createMock(McpToolRegistryInterface::class); + $registry->method('get')->willReturnCallback( + static fn (string $name): ?McpToolReference => $name === 'ping' + ? new McpToolReference( + name: 'ping', + title: 'Ping', + description: 'Liveness check.', + annotations: new ToolAnnotations(readOnlyHint: true), + outputSchema: null, + className: PingTool::class, + method: 'execute', + ) + : null + ); + $registry->method('getLocator')->willReturn(new ServiceLocator([ + PingTool::class => static fn (): PingTool => new PingTool(), + ])); + + return new McpServerFactory($registry, new Psr16Cache(new ArrayAdapter()), new NullLogger()); + } + + /** + * @param list $toolIds + */ + private function definition(array $toolIds): McpServerDefinition + { + return new McpServerDefinition( + id: 'demo', + displayName: 'Demo', + description: 'Demo server', + urlSlug: 'demo', + toolIds: $toolIds, + scopes: ['mcp:read'], + enabled: true, + access: new McpServerAccess(shareGlobal: true), + ); + } +} diff --git a/tests/Unit/Mcp/Service/McpServerConfigurationServiceTest.php b/tests/Unit/Mcp/Service/McpServerConfigurationServiceTest.php new file mode 100644 index 000000000..063cab82b --- /dev/null +++ b/tests/Unit/Mcp/Service/McpServerConfigurationServiceTest.php @@ -0,0 +1,317 @@ +repository(); + $service = $this->service($repository, self::USER_NAME); + + $server = $service->saveConfiguration($this->parameter('objects-read', tools: ['get_car_info'])); + + $this->assertSame('objects-read', $server->getId()); + $this->assertSame(self::USER_NAME, $server->getOwner()); + $this->assertSame(['mcp:read'], $server->getScopes()); + $this->assertSame('https://example.test/pimcore-mcp/studio/objects-read', $server->getUrl()); + // The owner has implicit read + edit, but access must be granted explicitly. + $permissions = $server->getCurrentUserPermissions(); + $this->assertTrue($permissions->isCanView()); + $this->assertFalse($permissions->isCanAccess()); + $this->assertTrue($permissions->isCanEdit()); + // The owner is NOT seeded into the sharing grid. + $this->assertSame([], $repository->get('objects-read')->access->sharedUsers); + } + + public function testSaveConfigurationPersistsGridWithoutSeedingTheOwner(): void + { + $repository = $this->repository(); + $service = $this->service($repository, self::USER_NAME); + + $service->saveConfiguration($this->parameter( + 'shared', + sharedUsers: [['name' => 'alice', 'canAccess' => true, 'canEdit' => false]], + sharedRoles: [['name' => 'editors', 'canAccess' => false, 'canEdit' => true]], + )); + + // Only the provided entries persist; the owner is not prepended. + $this->assertEquals( + [new McpServerAccessEntry('alice', canAccess: true, canEdit: false)], + $repository->get('shared')->access->sharedUsers + ); + $this->assertEquals( + [new McpServerAccessEntry('editors', canAccess: false, canEdit: true)], + $repository->get('shared')->access->sharedRoles + ); + } + + public function testSaveConfigurationThrowsWhenSlugAlreadyExists(): void + { + $repository = $this->repository(['taken' => $this->definition('taken', new McpServerAccess(owner: 'someone'))]); + $service = $this->service($repository, self::USER_NAME); + + $this->expectException(ElementExistsException::class); + + $service->saveConfiguration($this->parameter('taken')); + } + + public function testGetConfigurationAllowedForListedViewer(): void + { + $access = new McpServerAccess(owner: 'someone', sharedUsers: [new McpServerAccessEntry(self::USER_NAME)]); + $repository = $this->repository(['srv' => $this->definition('srv', $access)]); + $service = $this->service($repository, self::USER_NAME); + + $this->assertSame('https://example.test/pimcore-mcp/studio/srv', $service->getConfiguration('srv')->getUrl()); + } + + public function testGetConfigurationAllowedForAdmin(): void + { + $repository = $this->repository(['srv' => $this->definition('srv', new McpServerAccess(owner: 'someone'))]); + $service = $this->service($repository, self::USER_NAME, isAdmin: true); + + $this->assertSame('srv', $service->getConfiguration('srv')->getId()); + } + + public function testGetConfigurationDeniedWithoutAccess(): void + { + $repository = $this->repository(['srv' => $this->definition('srv', new McpServerAccess(owner: 'someone'))]); + $service = $this->service($repository, self::USER_NAME); + + $this->expectException(ForbiddenException::class); + + $service->getConfiguration('srv'); + } + + public function testUpdateConfigurationPreservesOwnerAndLocksSlug(): void + { + $repository = $this->repository(['srv' => $this->definition('srv', new McpServerAccess(owner: 'original.owner'))]); + // Admin has Edit on all servers; the original owner must be preserved. + $service = $this->service($repository, self::USER_NAME, isAdmin: true); + + $server = $service->updateConfiguration('srv', $this->parameter('ignored-slug', tools: ['get_car_info'])); + + $this->assertSame('srv', $server->getId()); + $this->assertSame('srv', $server->getUrlSlug()); + $this->assertSame('original.owner', $server->getOwner()); + $this->assertSame(['get_car_info'], $server->getTools()); + $this->assertSame('original.owner', $repository->get('srv')->access->owner); + } + + public function testUpdateConfigurationDeniedForViewOnlyUser(): void + { + $access = new McpServerAccess(owner: 'someone', sharedUsers: [new McpServerAccessEntry(self::USER_NAME)]); + $repository = $this->repository(['srv' => $this->definition('srv', $access)]); + $service = $this->service($repository, self::USER_NAME); + + $this->expectException(ForbiddenException::class); + + $service->updateConfiguration('srv', $this->parameter('srv')); + } + + public function testUpdateConfigurationThrowsNotFoundForUnknownId(): void + { + $service = $this->service($this->repository(), self::USER_NAME, isAdmin: true); + + $this->expectException(NotFoundException::class); + + $service->updateConfiguration('missing', $this->parameter('missing')); + } + + public function testListConfigurationsReturnsOnlyViewableServers(): void + { + $repository = $this->repository([ + 'mine' => $this->definition('mine', new McpServerAccess(owner: 'x', sharedUsers: [new McpServerAccessEntry(self::USER_NAME)])), + 'private' => $this->definition('private', new McpServerAccess(owner: 'x')), + 'public' => $this->definition('public', new McpServerAccess(owner: 'x', shareGlobal: true)), + ]); + $service = $this->service($repository, self::USER_NAME); + + $ids = array_map(static fn ($s) => $s->getId(), $service->listConfigurations()); + + $this->assertSame(['mine', 'public'], $ids); + } + + public function testListConfigurationsShowsEverythingToAdmin(): void + { + $repository = $this->repository([ + 'a' => $this->definition('a', new McpServerAccess(owner: 'x')), + 'b' => $this->definition('b', new McpServerAccess(owner: 'y')), + ]); + $service = $this->service($repository, self::USER_NAME, isAdmin: true); + + $this->assertCount(2, $service->listConfigurations()); + } + + public function testDeleteConfigurationAllowedForEditor(): void + { + $access = new McpServerAccess(owner: 'x', sharedUsers: [new McpServerAccessEntry(self::USER_NAME, canEdit: true)]); + $repository = $this->repository(['gone' => $this->definition('gone', $access)]); + $service = $this->service($repository, self::USER_NAME); + + $service->deleteConfiguration('gone'); + + $this->assertFalse($repository->has('gone')); + } + + public function testDeleteConfigurationDeniedForViewOnlyUser(): void + { + $access = new McpServerAccess(owner: 'x', sharedUsers: [new McpServerAccessEntry(self::USER_NAME)]); + $repository = $this->repository(['srv' => $this->definition('srv', $access)]); + $service = $this->service($repository, self::USER_NAME); + + $this->expectException(ForbiddenException::class); + + $service->deleteConfiguration('srv'); + } + + /** + * @param list $roles + */ + private function service( + McpServerConfigRepositoryInterface $repository, + string $currentUserName, + bool $isAdmin = false, + array $roles = [], + ): McpServerConfigurationService { + return new McpServerConfigurationService( + new McpServerHydrator(), + $this->makeEmpty(EventDispatcherInterface::class), + $repository, + new McpToolRegistry([ + 'get_car_info' => [ + 'class' => stdClass::class, 'method' => 'execute', 'title' => null, + 'description' => '', 'annotations' => ['readOnlyHint' => true], 'outputSchema' => null, + ], + 'delete_object' => [ + 'class' => stdClass::class, 'method' => 'execute', 'title' => null, + 'description' => '', 'annotations' => ['readOnlyHint' => false], 'outputSchema' => null, + ], + ]), + new McpServerAccessResolver($this->makeEmpty(RoleResolverInterface::class)), + $this->makeEmpty(SecurityServiceInterface::class, [ + 'getCurrentUser' => $this->makeEmpty(UserInterface::class, [ + 'getName' => $currentUserName, + 'isAdmin' => $isAdmin, + 'getRoles' => $roles, + ]), + ]), + self::ISSUER, + ); + } + + /** + * @param array $seed + */ + private function repository(array $seed = []): McpServerConfigRepositoryInterface + { + return new class($seed) implements McpServerConfigRepositoryInterface { + /** @param array $servers */ + public function __construct(private array $servers) + { + } + + public function list(): array + { + return array_values($this->servers); + } + + public function has(string $id): bool + { + return isset($this->servers[$id]); + } + + public function isWriteable(): bool + { + return true; + } + + public function get(string $id): McpServerDefinition + { + return $this->servers[$id] ?? throw new NotFoundException('MCP server', $id); + } + + public function save(McpServerDefinition $server): void + { + $this->servers[$server->id] = $server; + } + + public function delete(string $id): void + { + if (!isset($this->servers[$id])) { + throw new NotFoundException('MCP server', $id); + } + + unset($this->servers[$id]); + } + }; + } + + /** + * @param list $tools + * @param list $sharedUsers + * @param list $sharedRoles + */ + private function parameter(string $slug, array $tools = [], array $sharedUsers = [], array $sharedRoles = []): McpServerParameter + { + return new McpServerParameter( + name: 'Server ' . $slug, + urlSlug: $slug, + description: 'A server', + tools: $tools, + sharedUsers: $sharedUsers, + sharedRoles: $sharedRoles, + ); + } + + private function definition(string $id, McpServerAccess $access): McpServerDefinition + { + return new McpServerDefinition( + id: $id, + displayName: 'Server ' . $id, + description: '', + urlSlug: $id, + toolIds: ['get_car_info'], + scopes: ['mcp:read'], + enabled: true, + access: $access, + ); + } +} diff --git a/tests/Unit/Mcp/Service/McpToolCatalogueServiceTest.php b/tests/Unit/Mcp/Service/McpToolCatalogueServiceTest.php new file mode 100644 index 000000000..0e0ed232d --- /dev/null +++ b/tests/Unit/Mcp/Service/McpToolCatalogueServiceTest.php @@ -0,0 +1,83 @@ +makeEmpty(McpToolRegistryInterface::class, [ + 'all' => [ + $this->tool('ping', 'Ping', readOnly: true), + $this->tool('delete_object', 'Delete Object', readOnly: false), + ], + ]), + $this->makeEmpty(EventDispatcherInterface::class, [ + 'dispatch' => function (object $event, ?string $name = null) use (&$dispatched): object { + $dispatched[] = $name; + + return $event; + }, + ]), + ); + + $items = $service->listTools(); + + $this->assertCount(2, $items); + $this->assertSame(['ping', 'delete_object'], array_map(static fn ($i) => $i->getName(), $items)); + $this->assertSame('mcp:read', $items[0]->getRequiredScope()); + $this->assertSame('mcp:write', $items[1]->getRequiredScope()); + $this->assertSame([McpToolItemEvent::EVENT_NAME, McpToolItemEvent::EVENT_NAME], $dispatched); + } + + public function testListToolsReturnsEmptyArrayWhenNoToolsRegistered(): void + { + $service = new McpToolCatalogueService( + new McpToolItemHydrator(), + $this->makeEmpty(McpToolRegistryInterface::class, ['all' => []]), + $this->makeEmpty(EventDispatcherInterface::class), + ); + + $this->assertSame([], $service->listTools()); + } + + private function tool(string $name, string $title, bool $readOnly): McpToolReference + { + return new McpToolReference( + name: $name, + title: $title, + description: $title . ' tool', + annotations: new ToolAnnotations(readOnlyHint: $readOnly), + outputSchema: null, + className: stdClass::class, + method: 'execute', + ); + } +} diff --git a/tests/Unit/Mcp/Tool/Builtin/PingToolTest.php b/tests/Unit/Mcp/Tool/Builtin/PingToolTest.php new file mode 100644 index 000000000..aa8ef7486 --- /dev/null +++ b/tests/Unit/Mcp/Tool/Builtin/PingToolTest.php @@ -0,0 +1,34 @@ +execute(); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertFalse($result->isError); + + $content = $result->content[0]; + $this->assertInstanceOf(TextContent::class, $content); + $this->assertSame('pong', $content->text); + } +} diff --git a/tests/Unit/OAuth/Controller/AuthorizationServerMetadataControllerTest.php b/tests/Unit/OAuth/Controller/AuthorizationServerMetadataControllerTest.php index 3b05506b9..d855968f4 100644 --- a/tests/Unit/OAuth/Controller/AuthorizationServerMetadataControllerTest.php +++ b/tests/Unit/OAuth/Controller/AuthorizationServerMetadataControllerTest.php @@ -14,8 +14,10 @@ namespace Pimcore\Bundle\StudioBackendBundle\Tests\Unit\OAuth\Controller; use Codeception\Test\Unit; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\ScopeRegistryInterface; use Pimcore\Bundle\StudioBackendBundle\OAuth\Controller\AuthorizationServerMetadataController; use Symfony\Component\HttpFoundation\Request; +use function in_array; use function is_array; use function json_decode; @@ -28,7 +30,7 @@ final class AuthorizationServerMetadataControllerTest extends Unit public function testDoesNotAdvertiseTheRemovedClientCredentialsGrant(): void { - $metadata = $this->metadata(new AuthorizationServerMetadataController(self::ISSUER)); + $metadata = $this->metadata($this->controller()); // The client_credentials grant was removed; the metadata must not advertise it. $this->assertSame(['authorization_code', 'refresh_token'], $metadata['grant_types_supported']); @@ -36,22 +38,71 @@ public function testDoesNotAdvertiseTheRemovedClientCredentialsGrant(): void public function testAdvertisesTheCoreDiscoveryFields(): void { - $metadata = $this->metadata(new AuthorizationServerMetadataController(self::ISSUER)); + $metadata = $this->metadata($this->controller()); $this->assertSame(self::ISSUER, $metadata['issuer']); $this->assertSame(self::ISSUER . '/pimcore-oauth/token', $metadata['token_endpoint']); $this->assertSame(['S256'], $metadata['code_challenge_methods_supported']); } + public function testAdvertisesTheScopesContributedByTheRegistry(): void + { + // The catalogue is extensible, so the advertised scopes are whatever the + // registry holds -- not a list hard-coded in the controller. + $metadata = $this->metadata($this->controller(scopes: ['mcp:read', 'datahub:read'])); + + $this->assertSame(['mcp:read', 'datahub:read'], $metadata['scopes_supported']); + } + public function testRegistrationEndpointOnlyAdvertisedWhenDcrEnabled(): void { - $disabled = $this->metadata(new AuthorizationServerMetadataController(self::ISSUER)); + $disabled = $this->metadata($this->controller()); $this->assertArrayNotHasKey('registration_endpoint', $disabled); - $enabled = $this->metadata(new AuthorizationServerMetadataController(self::ISSUER, false, true)); + $enabled = $this->metadata($this->controller(registrationEnabled: true)); $this->assertSame(self::ISSUER . '/pimcore-oauth/register', $enabled['registration_endpoint']); } + /** + * @param list $scopes + */ + private function controller( + array $scopes = ['mcp:read'], + bool $registrationEnabled = false, + ): AuthorizationServerMetadataController { + return new AuthorizationServerMetadataController( + self::ISSUER, + $this->scopeRegistry($scopes), + false, + $registrationEnabled, + ); + } + + /** + * @param list $scopes + */ + private function scopeRegistry(array $scopes): ScopeRegistryInterface + { + return new class($scopes) implements ScopeRegistryInterface { + /** + * @param list $scopes + */ + public function __construct(private readonly array $scopes) + { + } + + public function all(): array + { + return $this->scopes; + } + + public function has(string $scope): bool + { + return in_array($scope, $this->scopes, true); + } + }; + } + /** * @return array */ diff --git a/tests/Unit/OAuth/Registry/ConfigProtectedResourceRegistryTest.php b/tests/Unit/OAuth/Registry/ConfigProtectedResourceRegistryTest.php index f96d53932..e70839f5f 100644 --- a/tests/Unit/OAuth/Registry/ConfigProtectedResourceRegistryTest.php +++ b/tests/Unit/OAuth/Registry/ConfigProtectedResourceRegistryTest.php @@ -79,4 +79,24 @@ public function testMetadataForReturnsRfc9728Document(): void $metadata->toArray() ); } + + /** + * A resource registered at runtime with a non-canonical URI is stored canonically, + * so the RFC 9728 document echoes the canonical `resource` whatever the caller + * spelled. Callers are told they need not canonicalise; this is what makes that true. + */ + public function testRuntimeRegistrationCanonicalisesTheStoredResource(): void + { + $registry = new ConfigProtectedResourceRegistry(); + $registry->register(new ProtectedResource('https://EXAMPLE.com:443/pimcore-mcp/', ['mcp:read'], [])); + + $resource = $registry->get('https://example.com/pimcore-mcp'); + + $this->assertNotNull($resource); + $this->assertSame('https://example.com/pimcore-mcp', $resource->canonicalUri); + $this->assertSame( + 'https://example.com/pimcore-mcp', + $registry->metadataFor('https://example.com/pimcore-mcp')?->toArray()['resource'] ?? null + ); + } } diff --git a/tests/Unit/OAuth/Server/ClientRegistrarTest.php b/tests/Unit/OAuth/Server/ClientRegistrarTest.php index fa8544b0c..4e26732fb 100644 --- a/tests/Unit/OAuth/Server/ClientRegistrarTest.php +++ b/tests/Unit/OAuth/Server/ClientRegistrarTest.php @@ -14,8 +14,10 @@ namespace Pimcore\Bundle\StudioBackendBundle\Tests\Unit\OAuth\Server; use Codeception\Test\Unit; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\ScopeProviderInterface; use Pimcore\Bundle\StudioBackendBundle\OAuth\Dto\DynamicClient; use Pimcore\Bundle\StudioBackendBundle\OAuth\Exception\ClientRegistrationException; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Registry\ScopeRegistry; use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\ClientRegistrar; use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Repository\DynamicClientStoreInterface; use function hash; @@ -44,7 +46,30 @@ public function find(string $identifier): ?DynamicClient } }; - $this->registrar = new ClientRegistrar($this->store); + $this->registrar = $this->createRegistrar('mcp:read', 'mcp:write'); + } + + /** + * A registrar whose scope catalogue is the real registry, fed by a single + * provider contributing exactly the given scopes. + */ + private function createRegistrar(string ...$scopes): ClientRegistrar + { + $provider = new class($scopes) implements ScopeProviderInterface { + /** + * @param list $scopes + */ + public function __construct(private readonly array $scopes) + { + } + + public function scopes(): array + { + return $this->scopes; + } + }; + + return new ClientRegistrar($this->store, new ScopeRegistry([$provider])); } public function testRegistersPublicClient(): void @@ -90,7 +115,7 @@ public function testDefaultsGrantAndScope(): void { $result = $this->registrar->register(['redirect_uris' => ['https://app.example/cb']]); $this->assertSame(['authorization_code'], $result->grantTypes); - $this->assertSame(['mcp:read'], $result->scopes); + $this->assertSame([], $result->scopes); } public function testAllowsLoopbackHttpRedirect(): void @@ -137,4 +162,46 @@ public function testRejectsUnsupportedScope(): void 'scope' => 'admin:all', ]); } + + public function testRegistersScopeContributedByAnotherBundle(): void + { + // The allowed scopes are whatever the registry holds, not a fixed list. + $result = $this->createRegistrar('datahub:read')->register([ + 'redirect_uris' => ['https://app.example/cb'], + 'scope' => 'datahub:read', + ]); + + $this->assertSame(['datahub:read'], $result->scopes); + } + + /** + * Deliberately NOT "the first scope in the registry": that order follows bundle + * registration, so the same registration would yield different scopes on different + * installations. A client that asks for no scope gets none. + */ + public function testOmittedScopeYieldsNoScopeRegardlessOfTheRegistry(): void + { + $result = $this->createRegistrar('datahub:read', 'datahub:write')->register([ + 'redirect_uris' => ['https://app.example/cb'], + ]); + + $this->assertSame([], $result->scopes); + } + + public function testEmptyRegistryYieldsNoDefaultScope(): void + { + $result = $this->createRegistrar()->register(['redirect_uris' => ['https://app.example/cb']]); + + $this->assertSame([], $result->scopes); + } + + public function testRejectsScopeMissingFromTheRegistry(): void + { + // `mcp:write` is only ever supported because a provider contributes it. + $this->expectException(ClientRegistrationException::class); + $this->createRegistrar('mcp:read')->register([ + 'redirect_uris' => ['https://app.example/cb'], + 'scope' => 'mcp:write', + ]); + } } diff --git a/tests/Unit/OAuth/Server/Grant/LoopbackAuthCodeGrantTest.php b/tests/Unit/OAuth/Server/Grant/LoopbackAuthCodeGrantTest.php index 6e102b44c..d11715559 100644 --- a/tests/Unit/OAuth/Server/Grant/LoopbackAuthCodeGrantTest.php +++ b/tests/Unit/OAuth/Server/Grant/LoopbackAuthCodeGrantTest.php @@ -20,18 +20,27 @@ use League\OAuth2\Server\Repositories\ClientRepositoryInterface; use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use Nyholm\Psr7\ServerRequest; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\ScopeProviderInterface; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Registry\ConfigProtectedResourceRegistry; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Registry\ScopeRegistry; use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Entity\ClientEntity; use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Grant\LoopbackAuthCodeGrant; use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Repository\ScopeRepository; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Repository\TokenRecordStoreInterface; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\RequestType\ResourceAuthorizationRequest; use Psr\Http\Message\ServerRequestInterface; final class LoopbackAuthCodeGrantTest extends Unit { private const string CLIENT_ID = 'test-client'; + private const string REDIRECT_URI = 'http://127.0.0.1:8080/callback'; + // A valid RFC 7636 code_challenge (43–128 chars of the unreserved set). private const string CODE_CHALLENGE = 'abcdefghijklmnopqrstuvwxyz0123456789-._~ABCDE'; + private const string KNOWN_RESOURCE = 'https://example.com/pimcore-mcp'; + private function grant(): LoopbackAuthCodeGrant { $grant = new LoopbackAuthCodeGrant( @@ -39,6 +48,14 @@ private function grant(): LoopbackAuthCodeGrant $this->createMock(RefreshTokenRepositoryInterface::class), new DateInterval('PT10M'), true, + new ConfigProtectedResourceRegistry([ + [ + 'uri' => self::KNOWN_RESOURCE, + 'scopes_supported' => ['mcp:read'], + 'authorization_servers' => ['https://example.com/pimcore-oauth'], + ], + ]), + $this->createMock(TokenRecordStoreInterface::class), ); $clientRepository = $this->createMock(ClientRepositoryInterface::class); @@ -46,12 +63,24 @@ private function grant(): LoopbackAuthCodeGrant new ClientEntity(self::CLIENT_ID, 'Test client', self::REDIRECT_URI), ); $grant->setClientRepository($clientRepository); - $grant->setScopeRepository(new ScopeRepository()); + $grant->setScopeRepository(new ScopeRepository($this->scopeRegistry())); $grant->setDefaultScope('mcp:read'); return $grant; } + private function scopeRegistry(): ScopeRegistry + { + return new ScopeRegistry([ + new class implements ScopeProviderInterface { + public function scopes(): array + { + return ['mcp:read', 'mcp:write']; + } + }, + ]); + } + /** * @param array $extra */ @@ -86,13 +115,15 @@ public function testOmittedCodeChallengeMethodDefaultsToPlainAndIsRejected(): vo /** * @param array $extra */ - private function assertRejectedAsInvalidRequest(array $extra): void + private function assertRejectedAsInvalidRequest(array $extra): OAuthServerException { try { $this->grant()->validateAuthorizationRequest($this->authorizeRequest($extra)); $this->fail('Expected the request to be rejected.'); } catch (OAuthServerException $exception) { $this->assertSame('invalid_request', $exception->getErrorType()); + + return $exception; } } @@ -101,9 +132,61 @@ public function testS256CodeChallengeIsAccepted(): void $authRequest = $this->grant()->validateAuthorizationRequest($this->authorizeRequest([ 'code_challenge' => self::CODE_CHALLENGE, 'code_challenge_method' => 'S256', + 'resource' => self::KNOWN_RESOURCE, ])); $this->assertSame(self::CLIENT_ID, $authRequest->getClient()->getIdentifier()); $this->assertSame('S256', $authRequest->getCodeChallengeMethod()); } + + public function testUnknownResourceIsRejected(): void + { + // RFC 8707: an unregistered audience must be refused, not silently ignored. + $exception = $this->assertRejectedAsInvalidRequest([ + 'code_challenge' => self::CODE_CHALLENGE, + 'code_challenge_method' => 'S256', + 'resource' => 'https://elsewhere.example/mcp', + ]); + + $this->assertStringContainsString('resource', (string) $exception->getHint()); + } + + public function testKnownResourceIsAcceptedAndCarriedOnTheRequest(): void + { + $authRequest = $this->grant()->validateAuthorizationRequest($this->authorizeRequest([ + 'code_challenge' => self::CODE_CHALLENGE, + 'code_challenge_method' => 'S256', + 'resource' => self::KNOWN_RESOURCE, + ])); + + $this->assertInstanceOf(ResourceAuthorizationRequest::class, $authRequest); + $this->assertSame(self::KNOWN_RESOURCE, $authRequest->getResource()); + } + + public function testResourceLookupIsCanonicalised(): void + { + // A trailing-slash / differently-cased variant of a registered resource + // is the same audience and must be accepted. + $authRequest = $this->grant()->validateAuthorizationRequest($this->authorizeRequest([ + 'code_challenge' => self::CODE_CHALLENGE, + 'code_challenge_method' => 'S256', + 'resource' => 'https://EXAMPLE.com/pimcore-mcp/', + ])); + + $this->assertInstanceOf(ResourceAuthorizationRequest::class, $authRequest); + } + + /** + * The resource is required, not optional: a token that named none would be accepted + * by every protected resource of this server, so a client cannot decline the binding. + */ + public function testRequestWithoutResourceIsRejected(): void + { + $exception = $this->assertRejectedAsInvalidRequest([ + 'code_challenge' => self::CODE_CHALLENGE, + 'code_challenge_method' => 'S256', + ]); + + $this->assertStringContainsString('resource', $exception->getHint() ?? ''); + } } diff --git a/tests/Unit/OAuth/Server/ScopeRepositoryTest.php b/tests/Unit/OAuth/Server/ScopeRepositoryTest.php index d4e3c52f1..5ccc1bd16 100644 --- a/tests/Unit/OAuth/Server/ScopeRepositoryTest.php +++ b/tests/Unit/OAuth/Server/ScopeRepositoryTest.php @@ -14,6 +14,8 @@ namespace Pimcore\Bundle\StudioBackendBundle\Tests\Unit\OAuth\Server; use Codeception\Test\Unit; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Contract\ScopeProviderInterface; +use Pimcore\Bundle\StudioBackendBundle\OAuth\Registry\ScopeRegistry; use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Entity\ClientEntity; use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Entity\ScopeEntity; use Pimcore\Bundle\StudioBackendBundle\OAuth\Server\Repository\ScopeRepository; @@ -22,20 +24,38 @@ final class ScopeRepositoryTest extends Unit { public function testResolvesSupportedScopes(): void { - $repo = new ScopeRepository(); + $repo = $this->repository('mcp:read', 'mcp:write'); $this->assertInstanceOf(ScopeEntity::class, $repo->getScopeEntityByIdentifier('mcp:read')); $this->assertInstanceOf(ScopeEntity::class, $repo->getScopeEntityByIdentifier('mcp:write')); } public function testRejectsUnknownScope(): void { - $this->assertNull((new ScopeRepository())->getScopeEntityByIdentifier('mcp:admin')); + $repo = $this->repository('mcp:read', 'mcp:write'); + $this->assertNull($repo->getScopeEntityByIdentifier('mcp:admin')); + } + + public function testResolvesScopeContributedByAnotherBundle(): void + { + // The catalogue is not hardcoded: a scope a provider contributes resolves. + $repo = $this->repository('datahub:read'); + + $scope = $repo->getScopeEntityByIdentifier('datahub:read'); + $this->assertInstanceOf(ScopeEntity::class, $scope); + $this->assertSame('datahub:read', $scope->getIdentifier()); + } + + public function testRejectsScopeMissingFromTheRegistry(): void + { + // Same identifier, different catalogue: with no provider contributing + // `mcp:write` it must be refused, proving the registry is what decides. + $this->assertNull($this->repository('mcp:read')->getScopeEntityByIdentifier('mcp:write')); } public function testFinalizeScopesPassesValidatedScopesThrough(): void { $scopes = [new ScopeEntity('mcp:read')]; - $finalized = (new ScopeRepository())->finalizeScopes( + $finalized = $this->repository('mcp:read')->finalizeScopes( $scopes, 'authorization_code', new ClientEntity('studio-mcp', 'Studio MCP', [], true), @@ -43,4 +63,27 @@ public function testFinalizeScopesPassesValidatedScopesThrough(): void $this->assertSame($scopes, $finalized); } + + /** + * A repository backed by the real registry, fed by a single provider that + * contributes exactly the given scopes. + */ + private function repository(string ...$scopes): ScopeRepository + { + $provider = new class($scopes) implements ScopeProviderInterface { + /** + * @param list $scopes + */ + public function __construct(private readonly array $scopes) + { + } + + public function scopes(): array + { + return $this->scopes; + } + }; + + return new ScopeRepository(new ScopeRegistry([$provider])); + } } diff --git a/tests/Unit/OAuth/Token/EmbeddedTokenValidatorTest.php b/tests/Unit/OAuth/Token/EmbeddedTokenValidatorTest.php index 5dd85f027..3ea50aa6d 100644 --- a/tests/Unit/OAuth/Token/EmbeddedTokenValidatorTest.php +++ b/tests/Unit/OAuth/Token/EmbeddedTokenValidatorTest.php @@ -24,6 +24,7 @@ use Pimcore\Bundle\StudioBackendBundle\OAuth\Token\EmbeddedTokenValidator; use Pimcore\Model\User; use Symfony\Component\Clock\MockClock; +use function array_key_exists; final class EmbeddedTokenValidatorTest extends Unit { @@ -52,6 +53,47 @@ public function testValidTokenResolvesAccess(): void $this->assertSame('studio-mcp', $access->clientId); } + /** + * RFC 8707: a token minted for one protected resource must not be accepted at + * another, which is what stops a token obtained for one application opening every + * other one on the same authorization server. + */ + public function testRejectsTokenMintedForAnotherResource(): void + { + $keys = $this->keyPair(); + $token = $this->mint($keys, ['aud' => 'https://example.com/pimcore-datahub-webservices/simplerest']); + + $this->assertNull($this->validator($keys['public'])->validate($token, self::RESOURCE)); + } + + /** + * The audience is compared canonically, so a token whose `aud` differs only by a + * trailing slash, host case or an explicit default port is still accepted. + */ + public function testAcceptsAnEquivalentButNonCanonicalAudience(): void + { + $user = new User(); + $user->setUsername('agent-user'); + $keys = $this->keyPair(); + $token = $this->mint($keys, ['aud' => 'https://PIMCORE.EXAMPLE.com:443/pimcore-mcp/']); + + $this->assertNotNull($this->validator($keys['public'], $user)->validate($token, self::RESOURCE)); + } + + /** + * A token that names no audience predates the binding or was issued for a request + * that named no resource, and stays valid so existing clients keep working. + */ + public function testAcceptsTokenWithoutAudience(): void + { + $user = new User(); + $user->setUsername('agent-user'); + $keys = $this->keyPair(); + $token = $this->mint($keys, ['aud' => null]); + + $this->assertNotNull($this->validator($keys['public'], $user)->validate($token, self::RESOURCE)); + } + public function testRejectsExpiredToken(): void { $keys = $this->keyPair(); @@ -196,11 +238,16 @@ private function mint(array $keys, array $overrides = []): string ->issuedBy($overrides['iss'] ?? self::ISSUER) ->relatedTo($overrides['sub'] ?? '42') ->identifiedBy($overrides['jti'] ?? 'jti-1') - ->permittedFor($overrides['aud'] ?? self::RESOURCE) ->issuedAt(new DateTimeImmutable($overrides['iat'] ?? '2026-07-15T12:00:00+00:00')) ->withClaim('scope', $overrides['scope'] ?? 'mcp:read mcp:write') ->withClaim('client_id', $overrides['client_id'] ?? 'studio-mcp'); + // array_key_exists (not ??) so an explicit null omits the audience entirely, + // which is how a token that names no resource is minted. + if (!array_key_exists('aud', $overrides) || $overrides['aud'] !== null) { + $builder = $builder->permittedFor($overrides['aud'] ?? self::RESOURCE); + } + // array_key_exists (not ??) so an explicit null omits the claim entirely. if (!array_key_exists('exp', $overrides) || $overrides['exp'] !== null) { $builder = $builder->expiresAt(new DateTimeImmutable($overrides['exp'] ?? '2026-07-15T13:00:00+00:00')); diff --git a/translations/studio_api_docs.en.yaml b/translations/studio_api_docs.en.yaml index 0c0c89d23..5e665ee44 100644 --- a/translations/studio_api_docs.en.yaml +++ b/translations/studio_api_docs.en.yaml @@ -774,6 +774,30 @@ login_token_summary: Session-based login with user token. logout_description: Logout and invalidate current session for active user. logout_success_response: Logout successful. logout_summary: Logout and invalidate current session for active user. +mcp_create_server_description: | + Create a new MCP server configuration. The urlSlug is used as the identifier and + determines the server's serving URL; it must be unique and contain only lowercase letters, digits and hyphens. +mcp_create_server_success_response: The created MCP server configuration +mcp_create_server_summary: Create an MCP server +mcp_delete_server_description: Delete an MCP server configuration by its url slug. +mcp_delete_server_success_response: MCP server configuration deleted successfully +mcp_delete_server_summary: Delete an MCP server +mcp_get_server_description: Get a single MCP server configuration by its url slug. +mcp_get_server_success_response: The requested MCP server configuration +mcp_get_server_summary: Get an MCP server +mcp_get_servers_description: | + Get all MCP server configurations the current user is allowed to see. +mcp_get_servers_success_response: List of MCP server configurations +mcp_get_servers_summary: Get all MCP servers +mcp_get_tools_description: | + Get the catalogue of MCP tools available for assignment to a server, contributed by the backend + bundle and any other bundle that registers tools. +mcp_get_tools_success_response: List of available MCP tools +mcp_get_tools_summary: Get the MCP tool catalogue +mcp_update_server_description: | + Update an existing MCP server configuration identified by its url slug. The url slug itself cannot be changed. +mcp_update_server_success_response: The updated MCP server configuration +mcp_update_server_summary: Update an MCP server mercure_create_cookie_description: Retrieve JWT token for Mercure hub as cookie mercure_create_cookie_success_response: Retrieve JWT token for Mercure hub as cookie mercure_create_cookie_summary: Retrieve JWT token for Mercure hub as cookie @@ -1124,6 +1148,7 @@ tag_get_collection_for_element_by_type_and_id_success_response: Paginated tags f tag_get_collection_for_element_by_type_and_id_summary: Get tags for an element tag_get_collection_success_response: All tags for a parent filtered based on type and query parameters tag_get_collection_summary: Get all tags for a parent +tag_mcp_description: MCP server management operations to get/create/update/delete servers and list available tools tag_mercure_description: Retrieve JWT token for Mercure hub as cookie tag_metadata_description: Metadata operations to get/update/create/delete metadata tag_notes_description: Note operations to list/delete notes