AI speaker coach - submit a talk proposal through MCP¶
Version v1
You can apply this model as a recipe (Drupal 10.3 or later) to your own Drupal site:
1 2 3 4 5 6 7 8 9 10 11 | |
Purpose¶
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.
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. "Look up the track" loads a published term from the session_track vocabulary whose name matches the submitted string. If no such term is accessible, the token stays unset and the request is rejected. The lookup is access-aware, so a term the submitting account may not view counts as unavailable, and the same lookup supplies the term id that "Set the track" writes to the reference field. One step both validates and resolves, so the two can never disagree.
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:
1 2 | |
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 wraps that object. ToolApi returns success, message, data and input_schema, and the values set here become the members of data, so a client reads data.status rather than a top-level status. Nothing in the model can change that envelope and nothing should try to imitate it.
A rejection is still a successful tool call. The bridge derives isError from !success, 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.
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.
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 as an SSE message event. The model's result is nested twice on the way out, at result.structuredContent.data.tool_output, because the bridge wraps every tool result in success, message and data. 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:
- It calls
POST /mcpwithout a token and receives401with aWWW-Authenticate: Bearerheader. - It reads
/.well-known/oauth-protected-resource, which names the authorization server and listsmcp:submit_talk_proposalamong the scopes this resource understands. The scope appears there becausemcp_server_oauthaggregates the scopes of every enabled tool mapping into that document. - It reads
/.well-known/oauth-authorization-server, which names/oauth/authorize,/oauth/token,/oauth/registerandS256. - It registers itself at
/oauth/registeras a public client withtoken_endpoint_auth_method: none, and receives a client id and no secret. - It opens
/oauth/authorizein 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. - 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:
1 2 3 4 5 6 7 8 | |
/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.
One thing a recipe cannot ship. Generate the OAuth key pair with drush simple-oauth:generate-keys and point simple_oauth.settings at it, then make sure the files are readable by the web server user. A key readable only by the console user fails every web request with "You need to set the OAuth2 private key" while the command line reads it perfectly. After that, give each speaker account the talk_submitter role. The role already carries grant simple_oauth codes, which the consent step needs, so nothing further has to be granted. 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 no account option: it runs as the command line account and therefore satisfies neither access mcp server nor this tool's permission.
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, the demo account and the tracks themselves are site-operator configuration and are not part of the recipe.
Dependencies¶
- config
- field.field.node.talk_proposal.field_abstract
- field.field.node.talk_proposal.field_audience_level
- field.field.node.talk_proposal.field_duration_minutes
- field.field.node.talk_proposal.field_track
- field.storage.node.field_abstract
- field.storage.node.field_audience_level
- field.storage.node.field_duration_minutes
- field.storage.node.field_track
- node.type.talk_proposal
- taxonomy.vocabulary.session_track
- module
- eca_base
- eca_content
- eca_tool
- modeler_api
- node
- options
- taxonomy
Used plugins¶
Events¶
Conditions¶
Actions¶
- Policy - submission window
- Policy - accepted audience levels
- Policy - default duration limit
- Policy - beginner duration limit
- Look up the track
- Reject - the window has not opened
- Reject - the window has closed
- Reject - unknown audience level
- Reject - unknown track
- Reject - the session is too long
- Create the proposal
- Set the abstract
- Set the track
- Set the audience level
- Set the duration
- Await human review
- Save the proposal
- Return the created proposal