Extending the MCP Server via App
Shopware apps can add custom tools, prompts, and resources to the MCP server through a declarative Resources/mcp.xml file. When an AI client calls an app capability with an external URL, Shopware sends an HMAC-signed POST to your app's endpoint and returns the response to the client. An internal path runs the capability as a Symfony subrequest, for example through an app script, without an external server.
Use an app when:
- Your capability runs on a remote service (ERP, PIM, CRM, SaaS backend)
- You want cloud compatibility (apps work in Shopware Cloud environments where plugins cannot run)
- Your capability needs to scale or deploy independently from Shopware
- You are building a SaaS integration where isolation matters more than in-process performance
For in-process PHP with full DAL access, see Extending via Plugin. For a side-by-side comparison of all three extension types, see Extending the MCP Server.
How app capabilities work
All three capability types (tools, prompts, resources) follow the same lifecycle:
| Step | Tools | Prompts | Resources |
|---|---|---|---|
| Declared in | mcp.xml | mcp.xml | mcp.xml |
| Persisted on install | app_mcp_tool | app_mcp_prompt | app_mcp_resource |
| Loaded at runtime | AppMcpToolLoader | AppMcpPromptLoader | AppMcpResourceLoader |
Shopware executes capabilities through an HMAC-signed POST for external URLs or a Symfony subrequest for internal paths; see URL modes: external vs internal.
Naming convention
Names declared in mcp.xml are automatically prefixed with the app name when the capability is loaded into the MCP registry:
app name: "my-erp"
declared name: "sync-orders"
→ final tool name: "my-erp-sync-orders"Names must only contain a-zA-Z0-9_- (no dots). The shopware- prefix is reserved for core tools; Shopware logs a warning and skips capabilities whose final names start with shopware-.
Tool groups and discovery
Shopware automatically groups all tools from an active app into a toolset named after the app's technical name. No additional field in mcp.xml is required. An MCP client can find the tools with shopware-tool-search or enable the complete app toolset with shopware-toolset-enable. The toolset title is generated from the app name by replacing hyphens with spaces and capitalizing the first letter, so an app named my-integration produces the title "My integration tools".
When an app is installed, updated, activated, deactivated, or deleted, Shopware queues the relevant notifications/*/list_changed message for the active MCP sessions, and only for the capability types the app actually declares. Clients can then refresh their tools, prompts, or resources.
How notifications reach the client
Notifications are queued into the MCP session rather than pushed. A client receives them with the response to its next request, or immediately over an open streaming connection. A client that neither keeps a stream open nor sends another request sees nothing. Queuing also requires a configured session store — see Session store.
Only the app lifecycle emits these notifications. Plugins and Symfony bundles register their capabilities at container build time, so their changes are not announced to running sessions.
mcp.xml structure
Place Resources/mcp.xml in your app bundle:
<?xml version="1.0" encoding="UTF-8"?>
<mcp xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/shopware/shopware/trunk/src/Core/Framework/App/Mcp/Schema/mcp-1.0.xsd">
<mcp-tools>
<mcp-tool name="sync-orders" url="https://app.example.com/mcp/sync-orders">
<label>Sync Orders</label>
<label lang="de-DE">Bestellungen synchronisieren</label>
<description>Synchronize orders with the external ERP system</description>
<input-schema>
<property name="since" type="string" description="ISO 8601 date, sync orders after this date" required="true"/>
<property name="limit" type="integer" description="Maximum number of orders to sync" required="false"/>
</input-schema>
<required-privileges>
<privilege>order:read</privilege>
<privilege>order:update</privilege>
</required-privileges>
</mcp-tool>
</mcp-tools>
<mcp-prompts>
<mcp-prompt name="erp-context" url="https://app.example.com/mcp/prompts/erp-context">
<label>ERP Context</label>
<description>Background context for working with ERP-synced data</description>
</mcp-prompt>
</mcp-prompts>
<mcp-resources>
<mcp-resource name="erp-status" uri="my-erp://erp-status"
url="https://app.example.com/mcp/resources/erp-status" mime-type="application/json">
<label>ERP Status</label>
<description>Current ERP connection status and last sync timestamp</description>
</mcp-resource>
</mcp-resources>
</mcp>Input schema properties
Each <property> maps to a JSON Schema property in the tool's inputSchema:
| Attribute | Required | Description |
|---|---|---|
name | yes | Parameter name |
type | no | JSON Schema type: string, integer, number, boolean, array, object (default: string) |
description | no | Description shown to the AI client |
required | no | Whether the parameter is required (default: false) |
Required privileges
List the ACL privileges your tool needs with <required-privileges>. The admin UI uses this list to warn operators when an integration's role is missing privileges a tool expects.
How strictly these privileges are enforced depends on the URL mode:
- External URL (
https://...): informational only. Shopware does not check these privileges before calling your webhook. Your app must enforce them on its own side - for example, by validatingsource.shopIdagainst what that shop is permitted to do in your backend. - Internal path (
/api/...): enforced by Shopware. The call is dispatched as a Symfony subrequest via the normal Admin API stack, so the integration's ACL role is checked just as with any other API call. Declare the privileges accurately so the Admin UI can warn operators about gaps.
URL modes: external vs internal
The url attribute supports two modes:
- External URL (
https://...): Shopware sends an HMAC-signed POST to your remote endpoint. External capabilities require a registered app secret. This is the default for SaaS integrations and any app hosted outside Shopware. - Internal path (
/...): Shopware dispatches the call as a Symfony subrequest. This lets an app use app scripts to serve capability logic without running an external server. Example:url="/api/script/mcp-greet". An app using only app scripts without registration has no app secret. Without a secret, Shopware does not load its prompts or resources, even with internal paths, and logs no warning about their omission. Internal-path tools do not require a secret.
Use the internal-path mode when your tool is a thin wrapper around data that Shopware already has. Use external URLs when you need to call out to an ERP, PIM, or any system that Shopware cannot reach directly.
Internal-path example: app script
Point the tool at an app script endpoint:
<mcp-tool name="greet" url="/api/script/mcp-greet">
<label>Greet</label>
<description>Return a greeting for the given name.</description>
<input-schema>
<property name="name" type="string" description="Name to greet" required="true"/>
</input-schema>
</mcp-tool>In Resources/scripts/api-mcp-greet/greet.twig, access the call's arguments directly through hook.request:
{% block response %}
{% set args = hook.request.arguments ?? {} %}
{% set name = args.name ?? 'World' %}
{% set response = services.response.json({
success: true,
data: { message: 'Hello, ' ~ name ~ '!' }
}) %}
{% do hook.setResponse(response) %}
{% endblock %}hook.request exposes the parsed POST body. Arguments live under hook.request.arguments. Internal calls inherit the parent request's authentication context and do not use webhook HMAC signatures. You only need to return the JSON envelope.
Webhook protocol
When the AI client calls a capability with an external URL, Shopware sends an HTTP POST to the URL declared in mcp.xml:
Request body:
The following tool, prompt, and resource request bodies are excerpts that omit source.appVersion.
{
"tool": "my-erp-sync-orders",
"arguments": {
"since": "2026-01-01",
"limit": 100
},
"source": {
"url": "https://shop.example.com",
"shopId": "abc123def456"
}
}| Field | Description |
|---|---|
tool | The full tool name (app prefix + declared name) |
arguments | The arguments provided by the AI client |
source.url | The shop's base URL |
source.shopId | Unique shop identifier. Use this for multi-tenant app backends |
source.appVersion | Installed app version for external tools; "0.0.0" for external prompts and resources |
For external webhooks, use source.appVersion when tool handling depends on the installed app version; prompts and resources receive the placeholder "0.0.0".
For prompts, the request body uses tool for the prompt name and includes an empty arguments array:
{
"tool": "my-erp-erp-context",
"arguments": [],
"source": {
"url": "https://shop.example.com",
"shopId": "abc123def456"
}
}The response must be a JSON array of message objects:
[
{"role": "user", "content": "You are working with ERP-synced Shopware data..."}
]For resources, the request body uses tool for the resource name and includes the resource URI in arguments:
{
"tool": "my-erp-erp-status",
"arguments": {
"uri": "my-erp://erp-status"
},
"source": {
"url": "https://shop.example.com",
"shopId": "abc123def456"
}
}The response must be a single object with uri, mimeType, and text:
{"uri": "my-erp://erp-status", "mimeType": "application/json", "text": "{\"connected\": true}"}Verifying the signature
Every external webhook request is signed with HMAC-SHA256 using the shop-specific secret exchanged during app registration. Always verify the shopware-shop-signature header before processing the request:
// Node.js example
const crypto = require('crypto');
function verifySignature(body, signature, appSecret) {
if (typeof signature !== 'string' || signature.length === 0) return false;
const expected = crypto
.createHmac('sha256', appSecret)
.update(body)
.digest();
const received = Buffer.from(signature, 'hex');
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(received, expected);
}
app.post('/mcp/sync-orders', (req, res) => {
const signature = req.headers['shopware-shop-signature'];
const rawBody = req.rawBody; // unparsed request body string
// Set APP_SECRET to the shop-specific secret exchanged during app registration.
if (!verifySignature(rawBody, signature, process.env.APP_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { arguments: args, source } = req.body;
// ... handle the tool call
});Returning a response
Return JSON as the tool result body. Shopware forwards it to the AI client as-is:
{"success": true, "data": {"synced": 42, "errors": 0}}For consistency with core tools, follow the {"success": bool, "data": ..., "_meta": ...} / {"success": false, "error": "..."} convention. Shopware does not reject responses that omit success, but it logs a warning when the field is missing so operators can spot broken handlers.
Timeout: App webhook calls time out after shopware.mcp.app_tool_timeout seconds (default: 10). Keep responses fast or use async patterns with status polling.
Locale resolution
Labels and descriptions in mcp.xml support translations via lang attributes. Shopware serves only the translation for the system default language to MCP clients. If you do not declare that language, Shopware uses the en-GB text, or the first translation you declared, when the app is installed or updated. Other translations are stored but are not served to MCP clients.
MCP clients such as Claude Desktop or Cursor show the capability title instead of the technical name. App tools and prompts forward their stored label as the title; app resources do not currently set a title.
Installing the app
Apps are installed through the standard Shopware app lifecycle (Settings → Extensions → My Apps, or via the Shopware CLI). After installation and activation, eligible MCP capabilities are loaded automatically into the server catalogue. Tools are initially deferred until the client discovers them or enables the app's toolset.
Verify with:
bin/console debug:mcpFor app tools, the Handler column shows (app-provided). To see what a specific integration can reach (respecting its per-integration allowlist), pass --integration=SWIA... with the integration's access key.
Further reading
- MCP Concepts: tools, resources, and prompts explained
- Best Practices: design principles for MCP tools
- Extending via Plugin: in-process PHP alternative
- McpHelloWorld: minimal example app registering tools, prompts, and resources via webhook