Minimal stack for MCP¶
This page lists the smallest set of modules a Drupal site needs to expose Native Observability over the Model Context Protocol (MCP) behind OAuth 2.1, and the recipe that installs it. The recipe installs the transport, the authorization layer and the native_observability_mcp submodule, which provides the tools and the vocabulary resource. It grants no permission and creates no role, account or AI provider.
Drupal 11 and PHP 8.3 only, today
The MCP and OAuth packages below are alpha or beta releases, and simple_oauth 6.1.4 cannot be installed on Drupal 12 yet. See Compatibility.
Install¶
Run the steps in this order.
-
Require the packages with Composer.
composer require \ 'drupal/mcp_server:2.0.0-beta7' \ 'drupal/mcp_server_tool_bridge:1.0.0-beta5' \ 'drupal/mcp_server_oauth:1.0.0-alpha2' \ 'drupal/simple_oauth:^6.1.4' \ 'drupal/tool:1.0.0-beta12' \ 'e0ipso/simple_oauth_21:1.13.1'- The stack packages are alpha and beta releases. Each one is named with its exact version, so Composer accepts them on a site with the default
minimum-stability: stable. Do not loosen the stability of the whole project for them. simple_oauth6.1.3 and older are blocked by Composer because of a security advisory: use 6.1.4 or later.- Do not add them to the
requireof this module. They stay insuggest: a pre-release dependency inrequirewould force its stability on every site that installs the module, including the ones that never use MCP. drupal/mcp_server_oauthbringse0ipso/simple_oauth_21, which provides the threesimple_oauth_*modules listed below.- Native Observability itself is required with Composer too (2.2.0 or later). The recipe enables it.
- The stack packages are alpha and beta releases. Each one is named with its exact version, so Composer accepts them on a site with the default
-
Rebuild the caches, so Drupal sees the new modules. Without it the recipe fails with "not a known module".
drush cr -
Apply the recipe. Pass
--uriwith the public address of the site.drush --uri=https://example.com recipe modules/contrib/native_observability/recipes/native_observability_mcp -
Do the site prerequisites, open registration, raise the exposure grade and create a role and a user for the client.
The path is relative to the web root, so a module in web/modules/contrib/ is addressed as modules/contrib/..., and a module in web/modules/custom/ as modules/custom/....
The recipe also enables the vocabulary resource provider and the schema resource template provider, so resources/list shows native-observability://vocabulary and resources/templates/list shows the schema template. Installing the submodule does not enable them: they are listed in mcp_server.resource_providers and mcp_server.resource_template_providers. The recipe ships both files in its config folder. They do not exist on a clean site, and with strict: true applying the recipe fails if a site already has them: in that case add the two entries to the plugins lists by hand.
The tools need mcp_server_oauth: the mcp_tool_config entities of the submodule, which carry the scopes, are created only when that module is present. Within one install step the submodule can be installed before mcp_server_oauth and then skips that optional config, so the recipe imports the tool and prompt configs by name after the modules are in. Which tools answer depends on the exposure grade the site chose; a new site is at grade 0.
The recipe uses config: strict: true, so reapplying it fails instead of silently overwriting an OAuth scope that a site has changed.
Modules the recipe installs¶
| Module | Reason |
|---|---|
mcp_server |
The /mcp endpoint and the HTTP transport. |
mcp_server_tool_bridge |
Exposes Tool API tools as MCP tools. The future observability tools are Tool API plugins. |
tool |
The Tool API that the bridge reads. |
mcp_server_oauth |
Gates tools/call on OAuth scopes and publishes the protected-resource metadata. |
simple_oauth |
The OAuth 2 authorization server (tokens, consumers, scopes). |
simple_oauth_pkce |
PKCE, mandatory for public MCP clients. |
simple_oauth_server_metadata |
/.well-known/oauth-authorization-server, so clients discover the endpoints. |
simple_oauth_client_registration |
Dynamic client registration, so a client can register itself. |
native_observability |
The data source: requests and traces. |
native_observability_spans |
Execution spans. Cache Observer does not need it, but without it the request spans cannot carry the cache summary. |
native_observability_metrics |
Aggregated route metrics, the source for the observability:read scope. |
native_observability_cache_observer |
Cache events. |
native_observability_database_observer |
Slow and relevant database queries. |
native_observability_mcp |
The MCP tools (including vocabulary_read, for clients that cannot read resources), the vocabulary resource and the prompts. |
Drupal also enables the dependencies of these modules: consumers, file, image, options, serialization and the parent module simple_oauth_21.
Modules the recipe does not install¶
| Module | Reason |
|---|---|
tool_belt, tool_belt_* |
A catalogue of generic content tools. The observability tools come from this module, and a generic catalogue would widen what an agent can reach. |
agent_access |
A different recipe that assembles its own MCP and OAuth stack with its own scopes. Applying both is not needed and would duplicate the OAuth setup. |
native_observability_dashboard |
In-site charts for humans. No MCP use. |
native_observability_report |
Printable reports for humans. |
native_observability_export |
Prometheus scrape endpoint and Elastic export, a separate delivery channel. |
native_observability_otel |
OTLP push exporter, a separate delivery channel. |
native_observability_eca_bridge |
Exposes events to ECA workflows. |
native_observability_status_block |
A block for the admin UI. |
OAuth scopes¶
The recipe ships three scopes, each bound to one existing permission. A scope only lets a token carry that permission: the user behind the token must hold the permission too, and the recipe grants it to nobody.
The name is what clients ask for and what the metadata list. The id in parentheses is the machine name of the scope entity in the admin UI and in configuration.
| Scope name (id) | Label | Permission |
|---|---|---|
observability:read (observability_read) |
Read aggregated observability data | view native observability metrics |
observability:raw (observability_raw) |
Read detailed observability data | view native observability traces |
observability:mcp:connect (observability_mcp_connect) |
Connect to the site MCP endpoint | access mcp server |
mcp_server_oauth itself does not require a connect scope: it only checks the scopes listed on each tool. The observability:mcp:connect scope is there so that a token can carry the access mcp server permission that the /mcp route requires.
Compatibility¶
Versions checked on 5 November 2026, on a new Drupal 11.4.11 site.
| Package | Version | Drupal core | PHP |
|---|---|---|---|
drupal/mcp_server |
2.0.0-beta7 | ^10 || ^11 | ^8.3 |
drupal/mcp_server_tool_bridge |
1.0.0-beta5 | ^10 || ^11 | |
drupal/mcp_server_oauth |
1.0.0-alpha2 | ^11 | |
drupal/simple_oauth |
6.1.4 | ^10.3 || ^11 | |
drupal/consumers |
1.24.0 | ^10.3 || ^11 | |
e0ipso/simple_oauth_21 |
1.13.1 | ||
drupal/tool |
1.0.0-beta12 | ^10.5 || ^11 |
Conclusion: the MCP and OAuth stack runs on Drupal 11 with PHP 8.3 or later. mcp_server_oauth declares Drupal 11 only, which rules out Drupal 10 for the whole stack.
Drupal 12¶
Not supported. A probe on Drupal 12.0.0-beta1 with PHP 8.5 resolved the packages with composer-drupal-lenient and patched core_version_requirement in their info.yml files, then stopped at installing simple_oauth:
Drupal\simple_oauth\Plugin\ScopeProviderManager only supports annotation-based
discovery, which is no longer supported as of Drupal 12.0.
Until simple_oauth ships an attribute-based plugin manager, the recipe cannot be applied on Drupal 12. The base modules of Native Observability support Drupal 12, the MCP submodule needs Drupal 11.
Site prerequisites¶
The recipe cannot provide these.
- HTTPS on the standard port. Serve the site over HTTPS on port 443.
simple_oauth_21builds the issuer ashttps://host/(with a trailing slash, and without a port), so on plain HTTP or on a non-standard port the discovery documents announce a wrong issuer and clients cannot sign in. The MCP tab shows a warning when the issuer does not match the address of the request. -
OAuth keys. Generate a key pair outside the web root and point
simple_oauth.settingsat it. The Drush command exists only oncesimple_oauthis enabled, so run it after the recipe. The directory must exist first.mkdir -p /path/outside/docroot/keys drush simple-oauth:generate-keys /path/outside/docroot/keys drush config:set simple_oauth.settings public_key /path/outside/docroot/keys/public.key drush config:set simple_oauth.settings private_key /path/outside/docroot/keys/private.key -
Trusted host patterns. Set
$settings['trusted_host_patterns']insettings.php, otherwise the metadata can announce any host a client sends and the HTTPS and public host check on the MCP tab stays at a warning. Escape the dots and anchor the pattern:$settings['trusted_host_patterns'] = [ '^example\.com$', ]; -
/.well-known/on the web server. Clients discover the authorization server at/.well-known/oauth-protected-resource/mcpand/.well-known/oauth-authorization-server. Many nginx configurations deny every dot path; allow this one and send it to Drupal. - Reverse proxy. Behind a proxy or load balancer, configure
reverse_proxyand the trusted headers insettings.php. Otherwise the metadata advertises the internal host and scheme instead of the public ones. - No interstitial pages on the MCP and OAuth paths. Some proxies answer with an HTML page of their own: a "starting" page while a scaled-to-zero site wakes up, a bot challenge, a maintenance notice. A browser copes with it. An MCP client does not: it expects JSON or an OAuth redirect, reads the page as a broken answer and stops signing in. Exempt
/mcp,/oauth/and/.well-known/oauth-from these pages, or make the proxy hold the request until the site answers.
The MCP tab fetches the two discovery documents to check them. A good answer is kept for 5 minutes, so the Discovery documents check does not show a change at once. A failed or wrong answer is not kept: reload the page after the fix. To refresh a good answer sooner, run drush cache:rebuild.
Open dynamic registration¶
A new site keeps dynamic client registration closed (registration.allow_dynamic: false). While it is closed, POST /oauth/register answers 403 with registration_disabled and the discovery documents carry no registration_endpoint, so a client cannot register itself. To open it, open the MCP tab (/admin/config/development/native-observability/settings/mcp), then Security and tick Allow dynamic client registration. Or use Drush:
drush config:set native_observability_mcp.settings registration.allow_dynamic 1 -y
Sites that already used registration before this switch existed stay open.
Raise the exposure grade¶
A new site is at grade 0: no tool returns data. Choose the grade on External exposure (/admin/config/development/native-observability/exposure). The grades are described in Configuration.
- Grade 1 serves aggregates by route name.
- Grade 2 serves request details and needs the recorded declaration. The MCP tools never return a path, a URL or an exception message: only route names, query keys without values and the exception class. The Prometheus export and OpenTelemetry follow their own rules at grade 2: see Configuration.
The observability:raw scope is withheld while the site is below grade 2. A client that signed in before the declaration has a token without it, and neither a refresh nor the raise of the grade adds it. After the declaration is recorded, the client must sign in again and ask for observability:raw. Tokens issued before keep the scopes they have.
Create a role and a user for the client¶
A token acts as the user who signed the client in, so create a dedicated one.
- Create a role with only the permissions the client needs:
access mcp server,grant simple_oauth codesandview native observability metrics, plusview native observability tracesfor the raw scope. - Create a user that has this role and no other, and no administrator role.
- Sign the client in with that user when the consent page asks for a login.
drush role:create mcp_agent 'MCP agent'
drush role:perm:add mcp_agent 'access mcp server,grant simple_oauth codes,view native observability metrics'
# Only for grade 2:
drush role:perm:add mcp_agent 'view native observability traces'
drush user:create mcp-agent --mail=mcp-agent@example.com --password='choose-a-long-password'
drush user:role:add mcp_agent mcp-agent
The administrator role (is_admin) always holds every permission, so a client signed in as an administrator can use all of them. The MCP tab lists such a role as information, not as a warning. It warns only for another role that has the permission to administer permissions or the site and also holds MCP access.
Verification¶
POST /mcpwithout a token returns401withWWW-Authenticate: Bearer realm="mcp_server", resource_metadata=".../.well-known/oauth-protected-resource/mcp".GET /.well-known/oauth-protected-resource/mcpandGET /.well-known/oauth-authorization-serverreturn200with JSON.- After a client signs in and an administrator approves it,
tools/listlists 8 tools, namedtool_api__<id>on the wire. - The MCP tab shows every status check green, including the issuer of the discovery documents.
Check the registration endpoint¶
Once registration is open, the endpoint is stored as the path /oauth/register. simple_oauth_server_metadata makes it absolute with the host of each request, so no host is stored.
drush config:get simple_oauth_server_metadata.settings registration_endpoint
curl -s https://example.com/.well-known/oauth-authorization-server | jq -r .registration_endpoint
Without jq:
curl -s https://example.com/.well-known/oauth-authorization-server | grep -o '"registration_endpoint":"[^"]*"'
The first command shows simple_oauth_server_metadata.settings:registration_endpoint: /oauth/register. The jq command shows https://example.com/oauth/register. The grep command shows "registration_endpoint":"https:\/\/example.com\/oauth\/register", with the slashes escaped as JSON does. While registration is closed grep prints nothing and jq prints null.
Security defaults¶
The submodule hardens the OAuth flow that MCP clients use. Everything below is on after install and after the update on existing sites, except the registration switch, which starts closed on a new site, and each rule has a switch in native_observability_mcp.settings.
| Rule | Switch |
|---|---|
A client that registers itself is created disabled, with PKCE, no remembered approval, no automatic authorization, only the authorization_code and refresh_token grants, an access token of 300 seconds, and no scope preset on the client. What a token gets depends on the scopes the client asks for, the consent of the user, the permissions of that user and the grade. |
security.harden_registered_clients, registration.require_approval, registration.refresh_token_lifetime (default 2592000, 30 days) |
POST /oauth/register is refused with invalid_redirect_uri unless redirect_uris lists at least one URI, and every URI is HTTPS or http:// on 127.0.0.1, [::1] or localhost. A grant_types outside the two above is refused with invalid_client_metadata. The same checks run on a client update. |
security.validate_registration |
At most 5 registrations per IP address per hour, then 429. |
registration.rate_limit (0 turns it off) |
The authorization request needs code_challenge with code_challenge_method=S256. A resource parameter must be the URL of the /mcp endpoint, otherwise invalid_target. |
security.require_pkce_s256, security.enforce_resource |
observability:raw is removed from every token, at the code exchange and at the refresh, unless the exposure grade is 2. |
security.restrict_raw_scope |
| A refresh token that was already used, presented again, revokes every token of that client and user. The event is written to the log. | security.detect_refresh_reuse |
A request to /mcp with an Origin header other than the site is answered with 403. A request without Origin passes. |
security.check_origin |
Scope of the rules: the registration checks and the rate limit apply to /oauth/register for the whole site, because the recipe installs dynamic registration for MCP. The PKCE S256 and resource checks at authorization apply only to requests that ask for an observability: scope or come from a client that registered itself dynamically. Other OAuth clients of the site, such as a decoupled front end, are not affected. The removal of the raw scope and the refresh token reuse detection act on every token of the site but only remove observability:raw or revoke a reused chain.
The registration endpoint is stored as the path /oauth/register, see Check the registration endpoint.
Approve a client¶
A client that registered itself cannot sign in until an administrator approves it. Open the MCP tab (/admin/config/development/native-observability/settings/mcp), check the name, the redirect URI and the contacts of the new client, and press Approve. A client you do not recognize is not approved: press Delete on it. To skip the approval on a site where registration is not public, set registration.require_approval to false.
Not covered
The aud claim of an access token is the client identifier, and simple_oauth has no setting to change it, so a token is not bound to /mcp by audience. The resource check above only keeps a client from asking for another target.
Machine-to-machine clients¶
The scopes the recipe creates accept the authorization_code and refresh_token grants only. A client that signs in as a person (Claude Code, an IDE) uses them as they are. A client that runs unattended with a static bearer token (a Neuron AI script, a scheduled job) needs the client_credentials grant, which the recipe leaves off on purpose: such a token acts as the default user of its consumer, with no sign-in. To allow it, enable client_credentials on the scopes the client needs, give the consumer a dedicated user with the observability permissions only, and never grant observability:raw to an unattended client unless the site is at grade 2 by a recorded declaration.