Drupal 11 + MCP: AI Content Search Without a Vector Database
Authored on
Every time someone says "let's add AI search," the next sentence is usually "so we'll need a vector database." Embeddings, chunking, a RAG pipeline, some new service to host and keep in sync with your content. I kept hearing it, and I kept thinking the same thing: my site already has a database. Drupal already knows what every field is and what it means. Why would I throw that structure away, blend my articles into a soup of vectors, and then pay a second system to guess the structure back out again?
So I tried the other thing. I built an AI content search for my own Drupal 11 site that never touches a vector DB, never chunks anything, and never does RAG. It uses MCP, two LLM calls, and one plain Drupal EntityQuery in between. It's running on my dev environment right now and it works — but I want to be upfront before you get excited: it's still in testing, and there's a security layer I want in place before this ever sees the public internet. More on that at the end. For now, here's how it got built, broken parts included.
The one rule I refused to break
Before I wrote a single line of code, I wrote down one non-negotiable: MCP plus the direct Drupal DB, nothing else. No vector store, no RAG, no chunking, ever. And then one more rule that turned out to be the whole trick — no LLM is ever allowed to touch the database.
The flow is three steps. An LLM reads your question and extracts what you're actually asking for — that's call #1. A structured EntityQuery runs against Drupal with those extracted terms, pure PHP, no model anywhere near it. Then a second LLM call takes the rows that came back and writes you a human answer — call #2. The model decides what to look for and how to phrase the result. It never decides what's in your database. Drupal does that, the way it always has.
Somewhere along the way I started calling it an answer engine instead of a retrieval engine, and honestly that framing kept me disciplined the whole project. The first call returns something boring and structured, like this:
{
"content_types": ["article"],
"keywords": ["twig", "template", "cache"],
"task_type": "howto"
}That JSON is the only thing the model hands to the query layer. From there it's just Drupal.
Telling it the schema instead of letting it guess
The part that makes this "schema-aware" rather than a glorified title dump is a YAML file. I declare which content types are searchable and, per type, which fields map to what. No runtime discovery, no magic. Right now it covers two types, article and note, and both use the same simple body_text strategy: match the keyword against the title or the body.
I'll admit I started this with a wrong assumption. I was sure note would have some kind of field_code for snippets, so I designed a separate strategy for it. Then I actually queried the live field definitions and — nope. A note is just a title and a body, structurally identical to an article. I deleted the special case. The strategy map stays in the config anyway, because the day I add a content type that does have a specialized field, I just add a strategy entry and nothing else changes.
The one place there's real structure is tags. Articles reference a tags vocabulary with about 19 actual terms — Drupal 8, API, Search, Plugins, and so on. The extractor gets that live term list in its system prompt, so when you ask for "articles about Search" it can map your intent straight onto a real taxonomy term and the query builder narrows on it. That's the difference between guessing and knowing.
Starting stupid-simple, then getting humbled by the API
I didn't begin with any of that. Phase zero was a hello-world: fetch up to 20 published article titles through an MCP tool, hand them to the model, get a coherent answer back. That's it. I just wanted to prove the plumbing end to end before building anything clever.
Two things bit me immediately, and they're the kind of thing nobody warns you about.
First, my module had quietly disabled itself during the move from Lando to DDEV. Worse, it wasn't in my exported core.extension.yml, which means a drush cim would have cheerfully uninstalled it for me. A re-enable and a config export fixed it, but it's a good reminder that "it was working last week" means nothing after an environment migration.
Second, the mcp_server contrib module had changed its plugin contract out from under me. I'd written my first tool against the old API — executeTool() and getTools() — and none of it fired. The current contract is a single method:
public function execute(array $arguments, ClientGateway $gateway): mixedI figured that out the unglamorous way: opening the upstream example tools and comparing them line by line against mine. No changelog drama, just read the code that works and match it.
Where the local model fought back
The whole thing is provider-agnostic — I'll get to why in a second — and a lot of my testing ran against a local llama3.2 through Ollama. Small models are fantastic for keeping the loop tight, but they have... personality.
The first surprise: llama3.2 would return an array field as a single string pretending to be an array. I'd ask for a list of keywords and get back something like this:
// What it actually sent for a "list" of keywords:
["[\"drupal\", \"template\"]"]
// One string. Sometimes Python-style instead: "['a','b']"So I wrote a little flattenArrayField() that detects that, unwraps it, and even decodes the single-quoted Python-repr version, because the model couldn't decide which flavor of wrong it preferred. Not elegant. Necessary.
The funnier one was tags. llama3.2 loves to hallucinate taxonomy terms that don't exist. Early on I applied the tag filter as a hard AND condition, and it would happily invent a tag, find zero matches, and wipe out perfect keyword hits. My article "Drupal 8 - Batch operations" has no tags at all, so a confident hallucinated tag filter made it vanish. The fix was to make the tag filter soft: narrow the results only if narrowing still leaves something behind. If the tag pass returns nothing, keep the keyword baseline. The keyword search is the floor; tags are a nice-to-have on top.
Two brains, one config line
Here's the part I'm most happy with. Switching the whole thing from Claude to a local model and back is a config change, not a code change. One environment variable picks the brain:
LLM_PROVIDER=anthropic # or 'ollama'
LLM_API_KEY=sk-ant-...
LLM_OLLAMA_URL=http://host.docker.internal:11434
LLM_OLLAMA_MODEL=llama3.2Flip LLM_PROVIDER, restart, done. The PHP doesn't know or care which one answered. That mattered more than I expected once I put it on a real VPS, because the honest truth about running a local model on a CPU-only box is that it's slow. Genuinely slow.
I'm not going to dress this up: an uncached search against llama3.2 on a shared, CPU-only VPS takes somewhere between 79 and 161 seconds. The first time I watched a query sit at 161 seconds I had to laugh. The exact same code on Anthropic comes back in one to three. That gap is the entire argument for the provider abstraction — you pick the brain that fits the environment, and the code never moves.
The other thing that saves the slow path is a dumb little query-hash cache. Same question, same config, you get the stored answer. The numbers tell the story better than I can: one query I tested took just over 161 seconds the first time and 19 milliseconds the second. That's roughly an 8,500× difference from a cache layer that took an afternoon to write. I'll take it.
(For the record, the slow cold start also taught me to stop hardcoding timeouts. The model unloads from memory after a few minutes idle, and the reload on the next request blew straight past my old 60-second cURL limit. It's a configurable LLM_TIMEOUT now, set to 180 on the slow box.)
Where it is right now — and why it's not public yet
So today there's a working search widget rendering on a page, an admin form to choose which content types it exposes, and a dashboard that logs every query with its provider, latency, cache status and result count. It's deployed and verified on my dev environment, behind basic auth. It runs on the local model there with zero code change from my local Anthropic setup.
I've also done a first pass at protection, because every search fires two LLM calls and an open endpoint is an invitation to run up a bill or a CPU. There's a dedicated permission instead of plain "access content," flood-based rate limiting, an input length cap that rejects before any model call, audit logging with bounded retention, and some prompt-injection hardening that wraps the user's query as untrusted data and refuses embedded instructions. I tested that last one with a live injection attempt and it held.
And still — I'm not opening this to the public yet. That in-app stuff is defense-in-depth, not a finish line. The real reason I'm comfortable being relaxed about it on dev is structural: the tool is read-only, it has no function or tool access of its own, and it only ever sees published content the caller could already read. There's nothing to exfiltrate and nothing to trigger. But "nothing to steal on dev" is a very different bar from "safe on the open internet," and I'd rather build a proper security layer around it first than ship it and hope. So it stays in testing for now. That's the honest status.
The bigger takeaway, if there is one: you probably don't need a vector database to do useful AI search on a Drupal site. You need to respect the structure you already paid for. Drupal knows your schema cold — let it do the part it's good at, and let the model do the two things it's actually good at, which is reading a question and writing an answer.
I'll write the follow-up once the security layer is in and this is properly out in the open. Until then — I hope some of this saves you an afternoon. Happy coding!