Search by

drupal-eca-recipe / eca_lib_0050

jurgenhaas

An external MCP assistant drafts a conference talk with a user and submits it to Drupal, which owns every rule. The model turns the five tool arguments into tokens, checks the submission window, the audience level, the track and a per-level duration limit before anything is written, and returns a st

Package info

gitlab.lakedrops.com/drupal/recipes/eca_lib_0050

Type:drupal-recipe

pkg:composer/drupal-eca-recipe/eca_lib_0050

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

1.1.1 2026-09-23 17:13 UTC

README

ID: eca_lib_0050

An external assistant drafts a conference talk with a user, the user approves it, and the assistant submits it to Drupal over MCP. Drupal decides whether the proposal is acceptable, who the speaker is, and whether anything gets written. The assistant only carries the text.

No AI runs inside Drupal. The model is deterministic, and every rule it applies is visible in the diagram.

Getting started

Applying the recipe brings the model, the content type, the workflow, the talk_submitter role, the tool mapping, the OAuth2 scope and five published tracks. What it cannot bring follows, in the order it has to happen, and the first step is where an afternoon goes missing.

  1. Generate the OAuth2 key pair. The directory has to exist and to sit outside the web root, and simple-oauth:generate-keys refuses a path inside the public files directory. On a site whose Drupal root is /var/www/html/web:

    mkdir -p /var/www/html/keys
    drush simple-oauth:generate-keys /var/www/html/keys
    

    The command writes private.key and public.key, each with mode 600, owned by whichever account ran Drush.

  2. Point simple_oauth.settings at the two files, with absolute paths. AuthorizationServerFactory::getPrivateKey() reads the value through FileSystem::realpath(), which resolves a relative path against the current working directory - the web root for a web request, and wherever you happen to stand for a Drush call.

    drush config:set simple_oauth.settings private_key /var/www/html/keys/private.key -y
    drush config:set simple_oauth.settings public_key /var/www/html/keys/public.key -y
    
  3. Hand the keys to the account php-fpm runs as. This is the step that gets skipped, because nothing on the console notices it is missing: the keys are 600 and owned by the console account, so Drush reads them perfectly while every web request fails with "You need to set the OAuth2 private key". The two accounts differ more often than not - in the wodby images the console is wodby and the pool is www-data.

    ps -o user=,group= -C php-fpm | sort -u
    chown www-data:www-data /var/www/html/keys/private.key /var/www/html/keys/public.key
    sudo -u www-data test -r /var/www/html/keys/private.key
    

    The last command exits 0 when the pool account can read the key, and that is the whole check. Substitute whatever the first command reports; www-data is what the wodby images use. A shared group does as well: chgrp www-data plus chmod 640. Do not go wider. League's CryptKey accepts 400, 440, 600, 640 and 660, and warns about anything else.

  4. Give each speaker account the talk_submitter role. The role carries grant simple_oauth codes, which the consent step needs, so nothing further has to be granted:

    drush user:role:add talk_submitter speaker1
    
  5. Read a discovery document over HTTP rather than from the command line, because the command line is the one reader that cannot fail:

    curl https://example.com/.well-known/oauth-protected-resource
    

    JSON means a client can find the authorization server. A 403 is the web server denying paths that begin with a dot, which is a vhost matter rather than a Drupal one; the trap is described under "Connecting a client".

  6. Edit the tracks. The five terms the recipe creates are ordinary content in the session_track vocabulary, at /admin/structure/taxonomy/manage/session_track/overview. Rename them, add your own, and unpublish rather than delete a track this conference does not run: the lookup accepts published terms only, and unpublishing leaves the proposals that already reference it intact.

  7. Set the submission window. "Policy - submission window" carries opens and closes as ISO dates and ships with 2026-01-01 and 2026-12-31. Every proposal sent outside that window is rejected with the date in the message, so a window left at the shipped dates rejects everything once they pass.

The tool

The "Submit a talk proposal" event is an ECA Tool event, so this model is a Tool API plugin. EcaToolEvent::generateWildcard() joins the model id and the event component id with a double colon, and EcaDeriver prefixes that with the base plugin id, so the plugin id here is eca:eca_lib_0050::submit_proposal. The MCP mapping has to name it exactly, because the bridge skips a mapping whose tool_id does not resolve without logging anything.

Execution is gated on create talk_proposal content, named in the event's permission setting. Left empty, ECA Tool would require its own execute eca tools permission, which is a site-wide grant to run every ECA tool and far wider than this recipe should hand to a demo account. Naming the permission the model's own actions already need keeps the role unchanged and the grant honest.

MCP Server Tool Bridge publishes that plugin to MCP clients. The recipe ships the mapping as mcp_server_tool_bridge.mcp_tool_config.submit_talk_proposal, whose tool_id holds the plugin id above. A tool_id that resolves to nothing is skipped in silence, with no warning and no log entry, so the tool simply never appears. Check that first if a client cannot see it.

The name on the wire is tool_api__submit_talk_proposal. McpToolConfig::getMcpWireName() hardcodes the tool_api__ prefix and offers no override, so the bare submit_talk_proposal is the logical name of the mapping rather than the name a client sees.

The event leaves operation and destructive at their defaults, write and TRUE, so clients are told destructiveHint: true and most will ask the user to confirm each call. Both are settings on the event rather than fixed values, and the default is kept here on purpose: the tool writes content, and the walkthrough has the user approve the draft before it is submitted anyway. Expect the prompt.

The event declares five arguments, and every top-level key in that YAML becomes a token of the same name for everything downstream. title, abstract, track and audience_level arrive as strings, duration_minutes as an integer.

There is deliberately no speaker argument. The speaker is the account the request authenticated as, read as [user:uid] and written to the node's author field by "Create the proposal". An assistant cannot submit on someone else's behalf by changing a payload, because there is no payload field that would let it.

Nothing is written until every rule has passed

The whole validation chain runs on tokens. The first action that touches the entity storage is "Create the proposal", and it sits behind every check. A rejected request therefore writes nothing at all - not a draft, not an unpublished node, not a log of a half-built entity.

Four rules are checked, in this order.

The submission window. "Policy - submission window" holds the two dates. Today's date is compared against them with the core [date:custom:Y-m-d] token, so the comparison is a plain lexical one between two ISO dates.

The audience level. "Policy - accepted audience levels" holds the list, and "List: contains item" tests the submitted value against it.

The track. "Is the track available?" asks whether the session_track vocabulary holds a published term whose name matches the submitted string, and "Look up the track" loads that term on the branch where a match is already proven. Both use the same criteria and both are access-aware, so a term the submitting account may not view counts as unavailable, and the load supplies the term id that "Set the track" writes to the reference field.

The check is a condition rather than an action on purpose. "Entity: load" is access-checked, and a lookup that matches nothing is denied - which in ECA skips the action and everything behind it, so a load placed in front of the gateway would take the rejection branch down with it. The failure is silent at the default log level and looks exactly like a model that never ran. Validating with "Entity: exists" and resolving with "Entity: load" keeps the rejection reachable.

The duration. "Policy - default duration limit" sets a limit of 60 minutes. For a beginner session, "Policy - beginner duration limit" overwrites it with 20. One comparison then covers every audience level.

The rule the demonstration changes

Step 6 of the walkthrough changes the beginner rule live and expects the tool to behave differently with no change on the assistant's side. That rule is a single action, "Policy - beginner duration limit", and it carries both halves of the rule together:

limit: 20
message: 'Beginner sessions are limited to 20 minutes.'

The comparison reads [duration_policy:limit] and the rejection reads both keys, so editing that one action changes the threshold, the returned maximum_duration and the explanation in a single edit. There is no second place where 20 is written down, and therefore no way for the number and the sentence to drift apart.

Both answers are structured

Every branch ends in "Set tool output" with the YAML option enabled, so the assistant receives an object rather than a sentence to parse.

A rejection carries status, reason and proposal_created: false, and the duration rejection adds maximum_duration. A success carries status, proposal_created: true and proposal_url.

The bridge does not wrap that object. ToolApi::execute() returns a CallToolResult whose structuredContent is the tool's outputs keyed by output name, so what this model sets lands at structuredContent.tool_output and a client reads structuredContent.tool_output.status. The message is the first text block and the same object is mirrored as JSON in a second one. Before MCP Server Tool Bridge 1.0.0-beta3 the bridge returned a success, message, data and input_schema envelope instead; that envelope is gone, and with it the extra data level.

Because the event declares its outputs, the bridge advertises an outputSchema as well: an object whose tool_output member carries the five declared properties, with status and proposal_created required inside it. A tool that declares no outputs gets no outputSchema and no structuredContent at all, only its message, so the typed outputs block on the event is what makes this tool's answer machine-readable.

A rejection is still a successful tool call. The bridge sets isError only for a Tool API failure, an access denial or an exception, and an error result tells a strict client that the tool malfunctioned rather than that the proposal needs work. Every branch of this model therefore ends in an ordinary output action. "Needs revision" is an answer, not a failure. Before 1.0.0-beta3 the bridge never set isError at all, so even a denied call reported success.

ECA replaces tokens in the output text before that text is read as YAML, and a token has to be quoted there for the text to stay valid YAML while it is still unreplaced. maximum_duration therefore arrives as the string "20" rather than the number 20. The README's example shows a number; it describes an application payload rather than a wire-level contract, and an assistant that reads the value is unaffected.

Proposal text is content

Nothing the assistant sends is ever evaluated. title, abstract and duration_minutes are only written into fields. audience_level and track are only compared against a list and used as a lookup key. No token from the request selects an action, sets an operator, names a field or chooses an account, so there is nothing for an instruction hidden in an abstract to take hold of.

What the model leaves to the site

The created proposal is unpublished and its moderation state is draft, the initial state of the talk_review workflow, which that workflow labels Submitted. Content moderation requires a workflow to define states with the IDs draft and published, so the two required IDs carry the Submitted and Accepted labels rather than adding two states the model never reaches. Publication is a human decision taken in Drupal, and the model has no branch that could reach it.

The submission window and the beginner rule live in the model because a Drupal recipe can only ship configuration that has a schema, and there is no core config object for "the dates our call for papers is open". Keeping them in the diagram also puts them where the demonstration already looks. The available tracks live outside the model, as published terms in the session_track vocabulary, because those change per event and are the operator's data rather than the model's policy. The recipe ships five of them as default content, so a site that was installed a minute ago answers a submission, and every one of them can be renamed, unpublished or replaced without touching the diagram.

Connecting a client

The endpoint is POST /mcp over Streamable HTTP. Send Content-Type: application/json and Accept: application/json, text/event-stream. initialize answers as JSON and returns an Mcp-Session-Id header that later calls repeat. A tools/call answers either as plain JSON or as an SSE message event - the SDK decides per request, and both shapes were observed within one session - so a client has to accept both. The model's result sits at result.structuredContent.tool_output, one level, keyed by the output the event declares. A rejection is still a successful call: isError stays false and the reason is in the payload.

Authentication is OAuth2, and a client that implements the MCP authorization spec arranges it on its own. There is no client id to paste into a configuration file, no secret to put in an environment variable, and no token to copy around. The speaker types their password into Drupal's own login form once, in a browser, and the client keeps a refreshable token afterwards.

mcp_server declares _auth: ['cookie'] on its route and the OAuth companion appends oauth2 to that same option, so the endpoint accepts a Bearer token as well as a session cookie. A cookie still works and is the shorter path for a first smoke test, but it authenticates one browser session rather than a speaker, which is the whole reason to prefer tokens.

Four modules make the automatic path work and the recipe brings all four. It installs simple_oauth, which issues the tokens, and mcp_server_oauth, which gates this tool on the scope mcp:submit_talk_proposal that the recipe ships mapped to the talk_submitter role. mcp_server_oauth depends in turn on simple_oauth_server_metadata, which publishes the two discovery documents, and on simple_oauth_client_registration, which lets a client register itself and fills the registration endpoint into the metadata from the site's own URL in its install hook. Naming those two in the install list as well would add nothing, and would make the recipe claim a composer package that does not exist.

What a client does on first use, with nothing arranged in advance:

  1. It calls POST /mcp without a token and receives 401 with a WWW-Authenticate: Bearer header.
  2. It reads /.well-known/oauth-protected-resource, which names the authorization server and lists mcp:submit_talk_proposal among the scopes this resource understands. The scope appears there because mcp_server_oauth aggregates the scopes of every enabled tool mapping into that document.
  3. It reads /.well-known/oauth-authorization-server, which names /oauth/authorize, /oauth/token, /oauth/register and S256.
  4. It registers itself at /oauth/register as a public client with token_endpoint_auth_method: none, and receives a client id and no secret.
  5. It opens /oauth/authorize in a browser with a PKCE challenge. Drupal shows its own login form and then the Grant Access to Client consent form, and the person presses Allow.
  6. It exchanges the returned code at /oauth/token, with the PKCE verifier and no secret, for an access token and a refresh token, and stores both in its own credential store.

With Oh My Pi the whole entry is the URL:

{
  "mcpServers": {
    "drupal": {
      "type": "http",
      "url": "https://example.com/mcp"
    }
  }
}

/mcp reauth drupal then runs those six steps and keeps the result in the active profile's credential store rather than in the file. The same committed mcp.json therefore serves every speaker: each one starts with omp --profile <speaker> and authorizes as themselves. A client that does not implement the flow can still be handed a Bearer token in a static Authorization header, but tokens here live five minutes, so that is a smoke test rather than a setup.

Open registration means anyone may create a client. It hands nobody access. The client still has to send a person to Drupal's login form, and the token it receives carries that person's account and nothing more. A token whose scope does not include mcp:submit_talk_proposal does not reach the tool at all: the scope is what grants the talk_submitter role, and without that role the account cannot open an MCP session, which the endpoint answers with 403.

Two things a recipe cannot ship: the OAuth2 key pair and the role assignment. Both are steps of "Getting started" above. A consumer entity is not on this list any more, because dynamic registration creates one per client, and that is precisely what leaves the client configuration empty.

One environment trap, and it is not Drupal's. The discovery documents live under /.well-known/, and several stock web server configurations deny any path segment that begins with a dot. The wodby/apache image does exactly that, with <FilesMatch "^\."> and <DirectoryMatch "^\.|\/\."> in its generated vhost, so every discovery document answers 403 before Drupal is reached and the client concludes that the server does not offer OAuth. The one-line check is curl https://example.com/.well-known/oauth-protected-resource, which has to return JSON. Drupal's own .htaccess already exempts .well-known, and an overriding FilesMatch in .htaccess loses against the vhost, so the fix belongs in the server configuration rather than in the site.

Two transport notes that cost time otherwise. Hosted connectors that cannot send custom headers are unaffected here, since the flow above needs no header of your making. And drush mcp:server, the STDIO transport, takes a required account argument since MCP Server 2.0.0-beta5 - a user name or a numeric ID, 0 for an explicit anonymous session - and runs the whole session as that account, so a permission-gated tool is evaluated against it. That still does not reach this tool: the recipe sets the mapping's authentication mode to required, STDIO carries no Bearer token, and the call is refused with authentication_required before the model runs, while tools/list keeps showing the tool. access mcp server never enters into it either way - that is a requirement of the HTTP route and is not evaluated on the STDIO path.

The token decides authorship, because the model reads the speaker from [user:uid]. The account that authorized the client owns the proposals that client submits, which is the point of tokens over one shared session: two speakers authorize separately and their proposals carry their own names.

Requirements

Beyond ECA itself this recipe installs eca_tool, tool, mcp_server, mcp_server_tool_bridge, simple_oauth and mcp_server_oauth, plus the core modules behind the content type - node, taxonomy, options, content_moderation and workflows. simple_oauth_server_metadata and simple_oauth_client_registration arrive as dependencies of mcp_server_oauth. All of those contributed modules have to be present on disk when the recipe is exported, because Modeler API silently drops a declared module it cannot find in the extension list.

The demo account needs access mcp server to reach the endpoint at all, and create talk_proposal content to execute the tool, because that is the permission the event names in its permission setting. MCP Server's ToolPluginBase::checkAccess() adds nothing beyond endpoint access and Tool API has no per-tool invoke permission of its own, so everything else the account may do is whatever this model's own actions ask for. That is why the role grants exactly create talk_proposal content, edit own talk_proposal content, view own unpublished content and the one workflow transition, and nothing that could publish.

ECA Tool and the MCP bridge are development and beta stage integrations. Transport, authentication and the demo account are site-operator configuration and are not part of the recipe. The tracks no longer are: the recipe creates five published terms in session_track as default content, which is a content directory beside recipe.yml that core imports on apply.

Installation

## Import recipe
composer require drupal-eca-recipe/eca_lib_0050

# Apply recipe with Drush (requires version 13 or later):
drush recipe ../recipes/eca_lib_0050

# Apply recipe without Drush:
cd web && php core/scripts/drupal recipe ../recipes/eca_lib_0050

# Rebuilding caches is optional, sometimes required:
drush cr