From ff799e9363c689fabe6b9faa257b8012ce556e86 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Fri, 28 Aug 2026 10:00:00 +0200 Subject: [PATCH] Add recipe for sulu/mcp-bundle 1.0 (#2034) * Add recipe for sulu/mcp-bundle 1.0 * Move the allowed hosts out of the recipe into its output The released sulu/mcp-bundle 1.0.0-RC1 requires symfony/mcp-bundle ^0.6, where mcp.http.allowed_hosts does not exist yet: it was added in 0.11. Shipping the option as config broke cache:clear on every install. * Contribute the allowed hosts and the default OAuth scopes sulu/mcp-bundle 1.0.0-RC2 requires symfony/mcp-bundle ^0.12, so mcp.http.allowed_hosts exists and the transport can be told its public host. league requires scopes.default and the bundle leaves it to the project, so the recipe contributes the two MCP scopes. Both sections live in sulu_mcp.yaml rather than in the files those bundles own, because Symfony merges configuration per extension. --------- Co-authored-by: Johannes Wachter --- .../1.0/config/packages/sulu_mcp.yaml | 28 +++++++ .../1.0/config/routes/sulu_mcp.yaml | 15 ++++ sulu/mcp-bundle/1.0/manifest.json | 73 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 sulu/mcp-bundle/1.0/config/packages/sulu_mcp.yaml create mode 100644 sulu/mcp-bundle/1.0/config/routes/sulu_mcp.yaml create mode 100644 sulu/mcp-bundle/1.0/manifest.json diff --git a/sulu/mcp-bundle/1.0/config/packages/sulu_mcp.yaml b/sulu/mcp-bundle/1.0/config/packages/sulu_mcp.yaml new file mode 100644 index 00000000..69c8781b --- /dev/null +++ b/sulu/mcp-bundle/1.0/config/packages/sulu_mcp.yaml @@ -0,0 +1,28 @@ +sulu_mcp: + # Publicly reachable base URL of this Sulu installation. Together with mcp_path + # it forms the OAuth issuer and the resource identifier of the discovery documents. + server_url: '%env(SULU_MCP_SERVER_URL)%' + +# The two sections below configure other bundles. They live in this file rather than in +# their own so they merge with whatever those bundles' recipes wrote, instead of taking +# ownership of a file that is not ours. + +mcp: + http: + # DNS rebinding protection. Left unset it accepts localhost only, so a server + # reachable under its own domain answers every request with "403 Forbidden: + # Invalid Host header." after the OAuth handshake already succeeded, and without + # writing anything to the log. Hosts carry no port. + allowed_hosts: + - '%env(key:host:url:SULU_MCP_SERVER_URL)%' + - localhost + - 127.0.0.1 + - '[::1]' + +league_oauth2_server: + scopes: + # `available` is contributed by SuluMcpBundle. `default` applies to clients that + # request no scope of their own, and league leaves it to the project. Scope lists + # are appended across files, so the entries the league recipe wrote stay; drop + # them there if the project does not use them. + default: ['mcp:tools', 'mcp:resources'] diff --git a/sulu/mcp-bundle/1.0/config/routes/sulu_mcp.yaml b/sulu/mcp-bundle/1.0/config/routes/sulu_mcp.yaml new file mode 100644 index 00000000..6496b58b --- /dev/null +++ b/sulu/mcp-bundle/1.0/config/routes/sulu_mcp.yaml @@ -0,0 +1,15 @@ +# MCP transport endpoint, registered by symfony/mcp-bundle at the path configured +# in config/packages/sulu_mcp.yaml. Declare it only once - a second "type: mcp" +# entry makes the route loader fail. +mcp: + resource: . + type: mcp + +# OAuth endpoints behind the admin prefix. The prefix must match mcp_path. +sulu_mcp_admin: + resource: '@SuluMcpBundle/config/routing_admin.yaml' + prefix: /admin + +# RFC 8414 / RFC 9728 discovery documents, unprefixed in the host's /.well-known/ namespace. +sulu_mcp_website: + resource: '@SuluMcpBundle/config/routing_website.yaml' diff --git a/sulu/mcp-bundle/1.0/manifest.json b/sulu/mcp-bundle/1.0/manifest.json new file mode 100644 index 00000000..5be9bb21 --- /dev/null +++ b/sulu/mcp-bundle/1.0/manifest.json @@ -0,0 +1,73 @@ +{ + "bundles": { + "Symfony\\AI\\McpBundle\\McpBundle": [ + "all" + ], + "Sulu\\Mcp\\Infrastructure\\Symfony\\HttpKernel\\SuluMcpBundle": [ + "all" + ] + }, + "copy-from-recipe": { + "config/": "%CONFIG_DIR%/" + }, + "env": { + "SULU_MCP_SERVER_URL": "https://localhost" + }, + "gitignore": [ + "/config/jwt/*.pem" + ], + "post-install-output": [ + " ", + " What's next? ", + " ", + "", + " * Set SULU_MCP_SERVER_URL in .env.local to the public base URL of this installation.", + "", + " * Generate the RSA key pair league/oauth2-server-bundle signs its tokens with,", + " and point the OAUTH_* variables at it. Without it every MCP request fails with", + " \"Invalid key supplied\":", + "", + " mkdir -p config/jwt", + " openssl genrsa -aes128 -out config/jwt/private.pem 4096", + " openssl rsa -in config/jwt/private.pem -pubout -out config/jwt/public.pem", + "", + " * The password and implicit grants of league/oauth2-server-bundle are deprecated while unset", + " and MCP does not use them. Turn them off in config/packages/league_oauth2_server.yaml:", + "", + " league_oauth2_server:", + " authorization_server:", + " enable_password_grant: false", + " enable_implicit_grant: false", + "", + " * Create the OAuth tables: bin/console doctrine:migrations:diff and migrate", + "", + " * Declare the MCP firewall in config/packages/security.yaml BEFORE the admin firewall.", + " Both patterns match /admin/mcp and Symfony applies the first one in declaration order:", + "", + " firewalls:", + " mcp:", + " pattern: ^/admin/mcp/?$", + " provider: sulu", + " stateless: true", + " entry_point: sulu_mcp.authentication_entry_point", + " oauth2: true", + " admin:", + " pattern: ^/admin(\\/|$)", + " # ...existing admin firewall...", + "", + " The pattern is anchored on purpose: /admin/mcp/authorize and /admin/mcp/consent/...", + " need the logged-in Sulu user and must fall through to the admin firewall.", + " Let the client-authenticated endpoints through in access_control:", + "", + " - { path: ^/\\.well-known/oauth-, roles: PUBLIC_ACCESS }", + " - { path: ^/admin/mcp/register$, roles: PUBLIC_ACCESS }", + " - { path: ^/admin/mcp/token$, roles: PUBLIC_ACCESS }", + " - { path: ^/admin/mcp/?$, roles: IS_AUTHENTICATED_FULLY }", + "", + " * Create an OAuth client for hosted clients such as Claude.ai or ChatGPT:", + " bin/console sulu:mcp:create-client \"Claude.ai Production\"", + " Claude Code registers itself dynamically and needs no client up front.", + "", + " * Full guide: https://github.com/sulu/SuluMcpBundle/blob/1.0/docs/configuration.md" + ] +}