Skip to content

[MCP] Server configuration management: definitions, CRUD, tools & access model - #2027

Draft
wwidergoldpimcore wants to merge 15 commits into
feature/oauth-1308-basic-integrationfrom
feature/mcp-1309-server-config-management
Draft

[MCP] Server configuration management: definitions, CRUD, tools & access model#2027
wwidergoldpimcore wants to merge 15 commits into
feature/oauth-1308-basic-integrationfrom
feature/mcp-1309-server-config-management

Conversation

@wwidergoldpimcore

Copy link
Copy Markdown
Contributor

Changes in this pull request

Draft / reference only. Not intended for imminent merge — opened to give a single view of everything being added under the scope of MCP server configuration management (product-management #1309). Stacked on top of the OAuth 2.1 branch (feature/oauth-1308-basic-integration, #1308), which is the intended near-term merge.

Adds user-configurable MCP servers to the Studio backend: define an MCP server, choose the tools it exposes, serve it over HTTP with OAuth-protected per-server access, and share it with other users/roles under a fine-grained capability model.

What it adds

  • Server definitions + storage — MCP server configurations backed by a location-aware config repository (shipped defaults from the studio_mcp_servers config node; runtime servers in the write target).
  • Tool framework + catalogue — tools self-register via a tag and are collected in a registry, unified on the SDK-native #[McpTool] contract; a tool catalogue is exposed for the UI.
  • HTTP serving + discovery — configured servers are served at /pimcore-mcp/studio/{slug} with per-server access checks and RFC 9728 discovery.
  • Studio-API CRUD — controller → service → hydrator → schema stack for create/read/update/delete of MCP servers, plus the tool catalogue endpoint.
  • Permission + migration — registers the mcp_servers permission on existing installs via a migration.

The access / sharing model (evolved over the branch)

  • A read/write sharing grid replaces the earlier flat use-lists, then is reworked into three independent capabilities — View / Access / Edit — matured into per-grant Config Read / MCP Server Access / Config Edit (canRead / canAccess / canEdit), with the invariant that Edit implies Read.
  • Sharing is keyed by user/role name (not id), so grants survive id changes.
  • Owner gets implicit Config Read + Edit, but MCP Server Access stays explicit; admins get implicit Read + Edit as well. These are enforced on write so an admin or the owner can never be persisted as read-only or non-editable, regardless of what the client submits.

Docs

Additional info

🤖 Generated with Claude Code

wwidergoldpimcore and others added 15 commits September 3, 2026 16:16
First step of #1309: a first-class, config-managed "MCP server" model, backend
only (no UI, no endpoint yet).

- McpServerDefinition / McpServerAccess value objects, with fromArray()/toArray()
  as the single (de)serialization boundary so the shipped symfony-config seed and
  the settings-store JSON map onto one shape. Access mirrors the SavedSearch/Grid
  sharing model (owner + shareGlobal + sharedUsers[] + sharedRoles[]), with users
  and roles in separate lists so a shared id is never ambiguous.
- McpServerConfigRepository over Pimcore's LocationAwareConfigRepository, mirroring
  PerspectiveConfigRepository: shipped defaults from the new studio_mcp_servers
  node, runtime servers from the configured write target (settings-store or
  symfony-config, deployer-switchable via config_location.studio_mcp_servers).
- Config tree + config_location node + prependCustomConfig wiring; the repository
  is registered and its config/storage args are set in the extension.

Inert by default: the node defaults to an empty map and nothing consumes the
repository yet. Tool registration, the per-URL endpoint, access enforcement and
OAuth discovery follow in later steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…istry

Second step of #1309: the mechanism by which tools become available to assign
to a server. No server/endpoint yet.

- McpToolInterface: a tool describes itself via getDefinition() (name, title,
  description, MCP annotations, JSON schemas) and runs via execute(). Native
  contract — the bundle does not depend on the MCP SDK; the per-server endpoint
  (later step) maps these onto the wire types.
- Implementing the interface auto-applies the McpToolRegistry::TAG (via
  registerForAutoconfiguration in the bundle), and McpToolRegistry collects the
  tagged tools through an #[AutowireIterator], name-keyed, rejecting duplicates.
  McpToolPass guards against a hand-written tag on a non-tool service.
- McpToolDefinition::requiredScope() derives the OAuth scope from the tool's
  readOnly annotation (read-only -> mcp:read, else mcp:write; unannotated
  defaults to write, fail-safe) — the basis for the operation-level scope
  enforcement tracked for a later step. Mirrors the agent bundle's PR #118
  ToolAnnotations, authored and enforced server-side.
- PingTool: a built-in, dependency-free read-only tool, so a server can be
  exercised end-to-end without the agent bundle.

Inert by default: the registry is populated but nothing consumes it yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd discovery

Makes step-1 testable end to end without a UI: a configured server is reachable
over MCP at /pimcore-mcp/studio/{server} under the shared pimcore_mcp firewall,
so it accepts the OAuth bearer.

- Adds the mcp/sdk dependency (^0.7, matching the agent bundle) and McpServerFactory,
  which assembles a tools-only Mcp\Server per definition. Each assigned tool is
  resolved from the registry and bridged onto the SDK: the native execute(array)
  is wrapped in a handler that reads the call arguments (via RequestContext /
  CallToolRequest) and maps the result to CallToolResult, so tools stay SDK-agnostic.
- McpServerController resolves the definition by URL slug, enforces per-server access
  (McpServerAccessResolver — admin/global/owner/user/role, mirroring the bundle's
  config sharing), and runs the streamable-HTTP transport with an explicit middleware
  stack (dropping the SDK's Dns-rebinding middleware, incompatible with a proxy). The
  route is namespaced under /studio/ and declared explicitly so it is neither swept
  under the Studio API prefix nor colliding with other bundles' /pimcore-mcp/ routes.
- The extension advertises each enabled server as an RFC 9728 protected resource
  (derived from the issuer), so the per-server 401 challenge and discovery resolve.
- A dedicated MCP session cache pool keeps sessions isolated from other bundles.

Verified live: unauthenticated -> 401 + WWW-Authenticate with the per-server
resource_metadata + scope; the resource metadata resolves 200; an authenticated
initialize -> tools/list -> tools/call ping returns "pong".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose the location-aware MCP server configuration over the Studio API so a
UI can manage servers without touching symfony-config or the settings store:

- Servers: GET/POST/PUT/DELETE under /pimcore-studio/api/mcp/servers, one
  action per controller, guarded by a new `mcp_servers` user permission.
- Tools: GET /pimcore-studio/api/mcp/tools returns the registry's tool
  catalogue (name, title, description, required scope, read-only/destructive
  hints) for assignment to a server.

The service derives a server's advertised OAuth scopes from its tools'
required scope, preserves the owner across updates, locks the url slug to the
id, and builds the serving URL from the OAuth issuer. Response DTOs flatten
the access model (owner/shareGlobal/sharedUsers/sharedRoles) and each carries
a pre-response event. Adds the MCP OpenAPI tag and translation keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g installs

The mcp_servers permission is created by the installer on a fresh install, but
that runs only once — instances updating from an earlier version never get the
row. Without it the Studio permission voter cannot resolve the attribute and
denies every user (admins included, since the admin bypass lives inside the
vote which never runs for an unsupported attribute), so the MCP server
management endpoints return 403.

The migration inserts the definition idempotently (guarded on existence, so it
is a no-op on a fresh install or a forward-merge replay) and drops the cached
permission-key list in postUp so the change takes effect without a separate
cache clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lists

Rework the per-server access model on the agent bundle's run/update pattern,
but kept deny-by-default and keyed by id:

- Two levels (McpServerPermission Read/Write, write implies read). Read = see
  the server, view its config, copy the URL, connect a client at runtime;
  Write = read plus edit, re-share and delete.
- Access is a grid: owner (implicit write) + global read flag + user/role share
  entries, each carrying a level (McpServerAccessEntry). The stored/​submitted
  shapes tolerate the earlier flat id lists, reading them as read grants.
- The resolver answers a requested level: admin, then owner, then an
  authoritative direct-user entry, then a granting role, then global-read,
  else deny. It backs both the Studio API and the runtime serving endpoint
  (which asks for Read).

Endpoint gating follows the single-permission path: mcp_servers now gates only
create and the tool catalogue. List/get are ungated and filtered/asserted by
read access, so a user a server is shared with — with no manage permission —
still sees it and copies its URL; update/delete assert write. The response
carries the caller's resolved permissions (currentUserPermissions) plus the
grid, so the UI can mirror the agent-bundle sharing editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two docs for the runtime MCP server feature:

- Extending/Providing MCP Tools: the McpToolInterface contract, the
  auto-applied pimcore.studio_backend.mcp_tool tag, annotations-to-scope
  mapping, and McpToolResult.
- Development Details/MCP Server Management: the mcp_servers permission, the
  settings-store write target, the Studio API surface, the read/write sharing
  model (deny-by-default, resolution order), and the Studio master/detail
  management UI including its read-only behavior.

Both are cross-linked with the existing MCP infrastructure and OAuth docs and
registered in their section indexes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the bundle's parallel MCP tool framework in favour of the mcp/sdk types
the agent bundle already uses, so studio tools and agent tools share one
contract:

- Delete McpToolInterface, McpToolDefinition, McpToolAnnotations, McpToolResult
  (and the now-dead DuplicateMcpToolException). A tool is now a plain service
  with an #[McpTool] method returning a CallToolResult.
- Tools opt in explicitly with the pimcore.studio_backend.mcp_tool tag (no
  auto-tag-by-interface); McpToolPass reflects the attribute into the registry
  and builds a service locator. Tool names must be unique.
- McpToolRegistry hands out McpToolReference descriptors (SDK Tool metadata +
  class/method); McpServerFactory registers them straight onto the SDK builder's
  addTool([class, method], ...) with a generated + normalized input schema — the
  former bridge closure and result mapping are gone.
- The only Pimcore-specific concern kept is the OAuth scope, now a one-line
  McpScopes::forReadOnly() helper over the tool's readOnlyHint.
- PingTool becomes the reference #[McpTool] example; the tool-authoring doc is
  rewritten around #[McpTool]/#[Schema]/CallToolResult.

Server management, sharing, the mcp_servers permission, the API and routes are
unchanged. Full cross-bundle unification of registries/routes is out of scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Configurations are meant to be portable across instances where the same user
or role carries a different numeric id, so the sharing model now identifies
users and roles by their (unique) name — matching how the agent bundle does it:

- McpServerAccess.owner and McpServerAccessEntry are name-based; the stored
  shape and the API (McpServer.owner, McpServerAccessGrant.name) use names.
- McpServerAccessResolver matches owner/user entries on the current user's name
  and resolves the user's role ids to names via RoleResolverInterface (the same
  id->name resolution the agent bundle performs).
- The service stamps the owner from getCurrentUser()->getName().

The feature is experimental and unreleased, so no id->name back-compat is kept;
the tolerant deserializer now reads a bare string as a read grant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the single read/write level with three independent capabilities on an
MCP server, matching the two-checkbox sharing the UI wants:

- A share entry is now { name, canAccess, canEdit }; being listed at all grants
  a read-only view. canAccess = connect a client at runtime; canEdit = change
  the config. They no longer imply each other.
- The resolver returns { view, access, edit } (union of the user's own and role
  entries): view = admin OR public OR listed; access = public OR a granting
  entry (admins do NOT get access implicitly); edit = admin OR a granting entry.
- shareGlobal is the "public" flag: any authenticated user may view and use
  (not edit) the server.
- The owner (creator) is auto-listed with full capabilities on save, so they
  keep view/use/edit of their own server.
- The Studio API gates get on view, put/delete on edit; the runtime serving
  endpoint requires access. The server list is filtered to viewable servers.

currentUserPermissions is now { canView, canAccess, canEdit }.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Studio-API write path moved MCP server access to name-based, two-capability
grants, but the symfony-config `studio_mcp_servers` tree still typed identities
as integers — so a YAML-configured server with a string `owner` failed at
container compile with `Expected "int", but got "string"`, and shared_users /
shared_roles could not carry the {name, can_access, can_edit} grid at all.

Align the file-config `access` node with the settings-store shape the repository
feeds into McpServerAccess::fromArray:
- owner is a scalar username (was integerNode)
- shared_users / shared_roles are grants of { name, can_access, can_edit }, and a
  bare string is accepted as a view-only grant (was integerPrototype)

Regression test processes the node and the full tree with a string owner and the
capability grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the refined permission model (#1452), the owner is now symmetric with an
admin: implicit Config Read + Config Edit, but MCP Server Access must be granted
explicitly — so nobody, not even the owner or an admin, has default access to a
server's runtime.

- McpServerAccessResolver: the owner resolves to view + edit (not access), like
  an admin. Access stays public-or-explicit-entry only.
- McpServerConfigurationService: stop seeding the owner into the sharing grid
  with full capabilities; the owner's read/edit is implicit, and they add
  themselves to the user list to grant access.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MCP Server Management page still described the original two-level read/write
sharing (owner with implicit write). Rewrite it to the current model: three
independent capabilities — Config Read, Config Edit, MCP Server Access — where
the owner and admins have implicit read+edit but must be granted access
explicitly, and a public server grants read+access (not edit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
View was derived from being listed, so read could not be withheld. Give each
grant its own canRead flag, so a user can hold Access without Read.

- McpServerAccessEntry: { name, canRead, canAccess, canEdit }; canRead normalises
  to true when canEdit (edit implies read); fromMixed defaults canRead true so
  grants stored before the flag keep "listed = read"; toArray adds can_read.
- McpServerAccessGrant: add canRead (required) + isCanRead().
- McpServerAccessResolver: view = admin || owner || public || a grant with
  canRead — drops the "listed = view" shortcut. Access/edit unchanged.
- Hydrator passes canRead through; docs updated (Config Read now per-grant, with
  the edit-implies-read invariant + back-compat default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Admins and the owner always hold Config Read + Edit at resolve time, but
the write path stored the grants verbatim — so an admin (or the owner)
submitted with Edit unchecked was persisted, and then returned, as
non-editable, which the UI faithfully rendered as a locked-down config.

Patch user grants on write instead of trusting the client to have
disabled the right checkboxes: any user entry belonging to the owner or
an admin is forced to Read + Edit (Access is preserved and never made
implicit). This keeps the stored config honest even when the frontend
has a bug or the owner is somehow entered as read-only. Roles are left
untouched — the admin/owner concept applies to users only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
1 New Critical Issues (required ≤ 0)
21 New Major Issues (required ≤ 0)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant