MCP Integration
The Model Context Protocol (MCP) is how the agent reaches every Pimcore capability - data objects, assets, documents, tags - and any third-party service you wire up. The bundle splits MCP servers into two families:
- internal (authored in PHP, served by Pimcore) and
- external (third-party).
They are authenticated and authorized differently.
Internal MCP servers
Internal MCP servers are PHP endpoints at /pimcore-mcp/agent/*, running inside the Pimcore application. They expose
data-object CRUD, asset management, document editing, tag operations, and anything else a bundle contributes via the
pimcore.mcp_tool service tag.
Tools are organised into tool groups by domain and access level (data-objects, assets, tags, documents, …).
The McpToolRegistryPass compiler pass collects every tagged service at container compilation and wires up one MCP
endpoint per group automatically. To add your own tools, see Extending → Custom MCP Tools.
The documents tool group has its own architectural layer because document editables are determined by Twig templates
rather than class definitions - see Document Schema for how the agent learns the editable
surface of a document or areabrick (AST analysis + co-located example fixtures).
Two ways to expose internal tools to an agent
An agent's YAML controls which internal tool groups it sees, and how the LLM sees them:
| Config key | Presentation | When to use |
|---|---|---|
pimcoreMcpServers | Full tool schemas loaded into context on every turn. LLM can call any tool in one roundtrip. | An agent's core tools - the ones it uses frequently. |
pimcoreMetaGroups | Tools wrapped behind a meta-tool server. LLM first discovers, then describe_tools, then executes. | Supplementary tools the agent needs sometimes but shouldn't pay for every turn. |
Both routes land on the same underlying PHP tools. The choice is about token budget and discoverability. An agent can
use both: core tools in pimcoreMcpServers, the long tail in pimcoreMetaGroups.
The meta-tool server
The meta-tool server lives at /pimcore-mcp/agent/meta?groups=<group1>,<group2> and exposes exactly three tools
regardless of how many PHP tools hide behind it:
| Tool | Purpose |
|---|---|
discover | List available groups and their tool names. |
describe_tool | Return the full parameter schema for a named tool. |
execute | Run a tool by name with JSON arguments. |
Allowed groups are set server-side from the agent's pimcoreMetaGroups; the LLM cannot influence the list. The token
overhead is constant at about 1,200 tokens regardless of group count - the main reason the bundle ships nearly every
Pimcore capability through the meta-tool by default.
External MCP servers
Third-party servers run independently of Pimcore and are configured per agent under mcpServers. HTTP transport,
typically bearer-token authenticated.
mcpServers:
pimcore-datahub:
type: http
url: ${MCP_PIMCORE_URL}
headers:
Authorization: "Bearer ${MCP_PIMCORE_TOKEN}"
tools: "*"
Environment variable interpolation is supported in url and headers. External servers do not participate in the
tool-group system and never receive a user cookie.
Authentication forwarding
The bearer token (Authorization: Bearer pmcp_…) is the credential for all server-to-server PHP calls
for a chat session - not just MCP. This covers both the internal MCP servers (/pimcore-mcp/agent/*) and
the bundle API (/pimcore-studio/api/bundle/agent/*). The agent-server holds two request-scoped store
instances:
PimcoreSessionStore.withBearer- used for all per-session operations on bundle/agent routes (proposal persistence, token refresh, etc.). Bound to the bearer at task start; survives cookie expiry.PimcoreSessionStore.withCookie- used only for session-independent operations (create/list sessions, bulk delete, auth-validator, bootstrapensureMcpToken).
McpAccessTokenAuthenticator in studio-backend resolves the bearer to the chat initiator's Pimcore User.
Pimcore enforces the same workspace ACLs and user/role permissions that apply in Studio. The chat session id is
bound to the token at issuance time and recovered server-side from the validated token - the previously-used
X-Chat-Session-Id request header has been removed (it was forgeable). Neither the bearer nor the chat session
id is controllable by the LLM.
External MCP servers (under mcpServers) never receive this bearer; they use whatever auth their YAML
configures. See Authentication for the agent-server-facing flows.
Token lifecycle
The token format is pmcp_<64 hex>. It is stored hashed (SHA-256) at rest in
bundle_studio_mcp_access_token; only the bearer the agent-server holds in memory is reversible.
Configurable TTL - pimcore_agent.chat_session_token.ttl (seconds, default 7200, minimum 60).
| Phase | What happens | Reminted? |
|---|---|---|
| Issue (new chat) | POST /pimcore-studio/api/bundle/agent/sessions returns { …, mcpToken: "pmcp_…" }. The agent-server bakes the bearer into the SDK session's MCP server headers. | n/a (first mint) |
| Refresh (per turn, user-interactive) | Before resuming an existing chat, the agent-server calls POST /sessions/{id}/mcp-token. If the live row's TTL is still in budget, PHP extends expires_at = now + ttl and returns { token: null, reminted: false } - the cached SDK session keeps its original bearer. This is the user-triggered path: it runs when a chat turn starts. | false (sliding-window extend) |
| Refresh (server-driven, autonomous) | While a task is running, TaskRunner calls refreshToken on a setInterval of max(60s, tokenTtlSeconds/2). The timer is cleared on every terminal status transition. This keeps long-running or overnight turns alive independently of any user interaction or cookie validity. Configured via AGENT_SERVER_MCP_TOKEN_TTL (see below). | false (sliding-window extend) |
| Re-mint (token gone) | If the row was GC'd or revoked, POST /sessions/{id}/mcp-token with { forceRemint: true } mints a fresh token under the same reference (chat session id), atomically deleting any prior row first, and returns { token: "pmcp_…", reminted: true }. The agent-server uses forceRemint on cold-resume after a restart, when no usable bearer is held in memory. | true |
| Revoke (chat deleted) | Single or bulk delete in the chat sidebar revokes the token row before the session record is removed. | n/a |
| Cascade (user deleted) | bundle_studio_mcp_access_token.user_id has ON DELETE CASCADE on users.id - deleting a Pimcore user atomically clears their tokens. | n/a |
| GC | The studio_mcp_access_token_gc Pimcore maintenance task removes any row whose expires_at < now(). | n/a |
The agent-server applies the result via the obtainMcpToken helper in routes/sse-helpers.ts. When reminted
is true the chat route passes tokenReminted: true to resumeSession, which forces the SDK session to be
destroyed and recreated - see SDK session rebuilds below.
SDK session rebuilds
The Copilot SDK caches an active session per chat with the MCP Authorization header baked into the attached
servers at construction time. The agent-server has three reasons to destroy and recreate that SDK session
(forceRecreate = isAgentSwitch || mcpResetPending || tokenReminted, see
adapters/copilot/copilot-adapter.ts:426):
- Agent switch - the user picked a different agent definition.
- MCP transport reset pending - see Transport-session recovery below.
- Token reminted - the cached bearer is stale; the new one must be re-baked.
The hot path (no switch, no reset, no re-mint) early-returns the cached session. Conversation context is
restored from the persistent session store via priorConversationContext on the first turn after a rebuild -
the user never sees the gap, only a small recovery delay before the next response.
Transport-session recovery
The Pimcore-internal MCP servers, like any HTTP MCP transport, have their own session lifetime. The PHP MCP SDK
garbage-collects idle sessions independently of the access token's TTL - short-lived in-memory transport
sessions can die even while the bearer is still valid. When the upstream session is gone, the next MCP call
returns 404 / 401 and the SDK surfaces the failure as a tool.execution_complete event with success: false -
but the SDK's onPostToolUse hook never fires for transport-level errors, so a "look at the result and
recover" approach in the application layer is not viable.
The agent-server addresses this by inspecting the raw SDK event stream (McpFailureInspector in
agent-server/src/adapters/copilot/mcp-failure-detector.ts):
- Maps
tool.execution_startevents totoolCallId → toolNameso it knows which server is in play. - On
tool.execution_completewithsuccess: false, matches the error message against a small set of patterns (Session not found,404,401,Unauthorized, …) - false positives only cost one extra destroy-and-recreate cycle, so the matcher is deliberately liberal. - Also reacts to
session.mcp_server_status_changedevents whose status isfailedorneeds-auth. - When triggered, marks the app session for SDK reset (
mcpResetPending).
On the next user turn, the Copilot adapter destroys the existing SDK session via deleteSession and re-creates it
with a fresh mcp-session-id for every attached MCP server. Conversation history is carried over via the persistent
session store (the same mechanism used for agent switching), so the user does not see a gap - only a small recovery
delay before the next response.
If the failure was a 401 caused by a stale bearer (after a re-mint that for any reason didn't reach the cached
SDK session - e.g. the recovery path), the chat route's retry callback forces tokenReminted: true so the
rebuild always picks up the freshest token.
Things to know
- Bearer never appears in browser network traffic. The token is server-side state in the agent-server
process. The browser only sees
Cookie(PHP session) on/agent-server/api/*and/pimcore-studio/api/*. If you seepmcp_in Chrome DevTools, that's only the one-shot response body when a chat is created or re-minted - never an upstream request header. - Logs are redacted. Pino's
redactconfig inagent-server/src/server.tslists bothreq.headers.authorizationandreq.headers.cookie, with a customserializers.reqthat emits headers (without it, redaction would be a silent no-op). The PHP side must do the same - see Installation → Logging. _mcp_token_referencerequest attribute. On a token-authenticated request, studio-backend stashes the chat session id onrequest->attributesunder that key. TheHasChatSessiontrait used by chat-scoped MCP tools reads it from there. The previously-supportedX-Chat-Session-Idrequest header has been removed- it was forgeable from the browser and is no longer trusted.
- HTTPS in production.
pmcp_…is a credential. Serve Studio (and the agent-server upstream link) over TLS - over plain HTTP these tokens are sniffable. - No effective-user override yet.
EffectiveUserResolvercurrently returns the chat initiator. Agent configs cannot impersonate other users.
Related reading
- Configuration → MCP Servers - configuration fields, direct vs meta-tool trade-off, tool group reference.
- Extending → Custom MCP Tools - add your own tools from a bundle.