Drupal 11 + MCP: Teaching a 10-Year-Old Site to Do Things, Not Just Answer Questions

K

Authored on

Image
pxl_20260720_004720851

Last time I wrote about MCP here, the model was on a very short leash. It could read my content, extract what you were asking for, and write you a summary. That's it. Read-only, no function access, nothing to trigger — and I said at the time that being read-only was exactly why I felt relaxed about running it on dev.

Then I wanted the opposite thing. I wanted an agent that could open a ticket for me. Start a timer. Leave a comment on a ticket it was already working on. Not answer questions about the site — actually operate the site.

That's a very different conversation, and most of what I read about it assumed I'd be starting from scratch. New headless stack, new schema, new everything. My site is not new. It's a Drupal site with years of accumulated board, ticket, and time-tracking logic that I actually use, and I had no interest in rebuilding any of it just so a model could poke at it.

So here's what it took to bolt MCP onto it instead. Including the day I took the whole endpoint down.

The part that took ten minutes

The starting point is the mcp_server contrib module, which turns a Drupal site into an MCP server, plus its mcp_server_tool_bridge submodule. The bridge does something genuinely nice: it lets you expose an existing Drupal action as an MCP tool with no code at all. One YAML config entity per tool and you're done.

Adding and removing a user role became an MCP-callable action in about the time it took me to write the YAML. No plugin, no class, no cache clear ritual. If your action already exists in Drupal and the inputs are simple, the bridge is the whole answer and you should stop there.

I did not stop there, because almost nothing I care about is that simple.

Where the YAML stops

The board and ticket system, time tracking, the internal AI-message channel — these have real domain logic. Field mappings, state transitions, entity references that need resolving before any of it makes sense as JSON. A generic action bridge isn't going to guess that.

So there's a small custom module, keboca_mcp, implementing proper Tool API plugins:

list_tickets        create_ticket       update_ticket
list_ticket_comments                    create_ticket_comment
start_timer         stop_timer
send_ai_message     read_ai_messages

Every plugin is the same two pieces: a normalizer that turns an entity into clean, boring JSON, and an access check. The access check is deliberately the same permission model that governs the admin UI. I did not want a second, parallel, "but for agents" security story to maintain. Whatever I can do in the browser, the agent can do — no more.

That list, by the way, got longer than I planned. The comments tools weren't in the original build. They exist because agents kept walking into the same wall: they could read a ticket and update a ticket, but the entire discussion thread on it was invisible to them. An agent that can change a ticket but can't see why the ticket was changed is not much of a colleague. So comments got their own pass.

That's been the pattern all month, honestly. The tool surface didn't grow because I designed it well up front. It grew because something kept hitting a wall and telling me about it.

The bug that ate a day

Auth runs over OAuth 2.1 — simple_oauth plus simple_oauth_21 — with scopes like keboca.tickets.read and keboca.tickets.write gating individual tools. Clean on paper.

Then every single tool call started failing authorization. With a valid token. Token exchange fine, scope present, request well-formed, and Drupal telling me no.

Here's what's happening: a client_credentials grant issues a JWT with no sub claim. There's no user in that flow — that's the entire point of machine-to-machine auth. But Drupal's permission layer, downstream, still wants to resolve an account to check permissions against, and what it resolves is effectively nobody. Nobody has no ECK entity permissions. So the entity-level access check on every tool returns false, forever, no matter how correct your token is.

You can chase that for a while thinking it's a scope problem. It isn't. The token was never the issue.

The fix was to stop asking the wrong question. In McpManager.php, MCP calls no longer check entity-level permissions at all — they gate on a single access mcp server permission, granted to both anonymous and authenticated roles. The real security boundary moves up to where it should have been all along: the OAuth scope on the request, plus an authentication_mode: required flag on each tool's config.

Which feels wrong for about five minutes, and then stops feeling wrong. Entity permissions answer "what may this user do." A machine token has no user. Scopes answer "what may this client do," and that's the actual question.

A patch going back upstream

While I was in there, tools/call over HTTP started returning a JSON-RPC -32603 — internal error, no detail, thanks very much.

That one's not mine. In mcp/sdk 0.5, the addTool() closure doesn't pass a RequestContext through, so anything invoking a tool over HTTP dies on the way in. It's fixed locally with a Composer patch, and it's queued to go back to the mcp_server project on drupal.org as a proper issue rather than living in my repo forever.

I mention it mostly so you know: if you're doing this now, you are early. The contrib layer is moving. Read the upstream example tools and compare them line by line against yours when something silently doesn't fire — that's twice now that's been the answer for me.

One pipeline, two front doors

Somewhere in the middle of this the search side grew a second consumer, and it turned into the part I'm quietly happiest with.

The AI search from the last article was reachable one way: as an MCP tool. Then I wanted it on an actual page, for actual humans, so I built the widget at /ai-search as an Angular 21 Custom Element.

The temptation there is obvious — write a controller, orchestrate the two LLM calls and the EntityQuery again, ship it. And then have two implementations of the same pipeline drifting apart for the rest of time.

Instead the orchestration got extracted into one SearchPipeline service. The MCP tool (ContentSearchTool) calls it. The HTTP controller (AiSearchController) calls it. Neither knows the other exists:

Browser → <ai-search-component> → POST /api/ai-search → SearchPipeline → JSON
Agent   → /_mcp tools/call      → ContentSearchTool  → SearchPipeline → JSON

A CMS, an AI pipeline, and an Angular SPA component, interlocking over plain HTTP and a Custom Element, none of them knowing anything about the others' internals. Lego blocks. That's the mental model I keep coming back to, and it's kept me from making a mess more than once.

One small thing that fell out of the refactor and is worth stealing: SummaryGenerator used to re-load the nodes it was summarizing. It now takes the already-loaded result cards instead. Same output, and a search loads matching content exactly once instead of twice. Not clever, just the kind of thing you only notice when two callers force you to look at the seams.

The day /_mcp returned 500 for everything

Okay, the unglamorous one.

I merged a routine security-update PR. It deleted the mcp_ai_search module directory from disk. What it did not do was run drush pm:uninstall, so as far as Drupal was concerned the module was still installed — still sitting right there in core.extension.module.

Every request to /_mcp then died:

Class "Drupal\mcp_ai_search\Plugin\Mcp\Tool\ContentSearchTool" not found
→ HTTP 500

Not degraded. Not "that one tool is unavailable." The entire MCP endpoint, dead, because the server builds its tool registry at request time and one missing plugin class takes the whole thing down with it. Every other tool — tickets, timers, messages — gone too, and none of them had anything to do with the module I'd removed.

The fix was one command and a cache rebuild. The lesson took longer to sink in:

Deleting a module's files is not the same as uninstalling it. I have known that for years. I still merged the PR.

And the second one, which was new to me: an MCP server's tool registry is a single point of failure. One bad plugin doesn't disable one tool, it 500s your whole agent surface. If you're running this anywhere that matters, that's worth knowing before it happens rather than while you're reading a stack trace.

What actually transferred

None of this needed a rewrite. No headless migration, no separate services layer, no new schema. The site is the same site it was in June, with a handful of small modules bolted on.

The shape that emerged, if you want to skip to the part that generalizes:

Config-driven bridging handles simple actions and costs you nothing. Small typed plugins handle anything with real business logic, and each one is a normalizer plus an access check — that's the whole plugin. OAuth scopes are your permission boundary, not entity permissions, because machine tokens have no user to hang entity permissions on. And extract the pipeline the moment a second caller shows up, not after.

The rest is the tax on being early: JWT claims that don't line up with the permission model you already have, an SDK version that quietly changed its contract, and a tool registry that will take your endpoint down over one missing class. All fixable. All findable, eventually. Just budget for them.

The search half of this is still dev-only behind basic auth until I've built out the security layer properly — that hasn't changed. The tool bridge, though, has been running against my real board and tickets for weeks now, and I open fewer admin tabs than I used to.

I hope some of this saves you an afternoon. Happy coding!