Drupal Connector — Integration Pattern
Documents the Drupal 11 integration pattern. Read alongside mcp-patterns.md and llm-orchestration.md.
Architecture overview
Section titled “Architecture overview”Drupal page (browser) └─ cinatra PHP module injects bundle + drupalSettings └─ /api/drupal/bundle.js (IIFE, Shadow DOM widget) └─ POST /api/agents/drupal-content-editor/stream [CORS + Bearer] └─ LLM turn (`stream`, @cinatra-ai/llm) — tools: drupal_content_editor_run function tool + drupal-widget-chat skill; SSE text/changes/error/done └─ drupal_content_editor_run handler (connector) — A2A dispatch via DI: deps.dispatchContentEditor → host dispatchContentEditorViaA2A └─ createExternalA2AClient → drupal-content-editor (WayFlow) └─ ApiNode → /api/llm-bridge → drupal_node_* MCP primitives → /api/mcp └─ callDrupalMcp → <siteUrl>/_mcp_tools [Bearer] └─ drupal/mcp_tools Streamable HTTP endpointThe widget request is not a direct agent-to-agent (A2A) dispatch. The stream route runs a normal LLM turn with exactly one function tool (drupal_content_editor_run) plus the drupal-widget-chat skill; the LLM decides whether to answer conversationally or call the tool. The A2A dispatch to the WayFlow agent happens only when the tool is called, and it lives behind the connector handler’s dependency-injection (DI) seam (deps.dispatchContentEditor, bound at connector activation to the host’s dispatchContentEditorViaA2A via the content-editor-dispatch capability).
Key files
Section titled “Key files”| File | Role |
|---|---|
drupal-mcp-connector/src/lib/drupal-instances.ts |
Instance CRUD + DrupalInstanceSettings type — connector-owned, registered under the @cinatra-ai/host:drupal-mcp capability |
drupal-mcp-connector/src/lib/drupal-mcp-client.ts |
callDrupalMcp — Model Context Protocol (MCP) SDK Client + StreamableHTTPClientTransport |
src/lib/drupal-mcp-connection.ts |
buildDrupalMcpServerTools — probe cache + large language model (LLM) tool registration |
drupal-mcp-connector/src/register.ts |
Connector-owned widget-auth store (UUID-pair widget API key), registered as the @cinatra-ai/host:drupal-widget-auth capability |
drupal-mcp-connector/src/mcp/handlers.ts |
8 primitive handlers (7 Drupal MCP-facing + drupal_content_editor_run, which dispatches A2A via the host-bound deps.dispatchContentEditor) |
drupal-mcp-connector/src/widget-chat-tool.ts |
createDrupalWidgetChatTool — the LLM function tool the stream route exposes; overrides identity fields from server-trusted context |
drupal-mcp-connector/src/deps.ts |
Host DI contract (registerDrupalConnector / getDrupalDeps): pagination, dispatchContentEditor, buildNangoBearerHeader |
src/lib/host-content-editor-dispatch.ts |
dispatchContentEditorViaA2A — host-side A2A blocking dispatch (bearer mint + external client + task.history walk), shared with WordPress |
drupal-mcp-connector/src/register.ts (register(ctx)) |
Activation-time DI binding: the connector resolves the per-concern host capabilities (content-editor dispatch, Nango bearer builder, pagination) via ctx.capabilities and binds its own deps slot |
src/app/api/agents/[agentSlug]/stream/route.ts |
Per-slug agent stream registry (drupal-content-editor + wordpress-content-editor): CORS + Bearer + contract gates, LLM-orchestrated turn with the widget-chat tool, server-sent events (SSE) |
drupal-module/js/cinatra-widget.js |
The vendored widget bundle the Drupal module attaches locally (the former host bundle.js route was removed — see docs/widget-source-of-truth.md in the platform repo) |
drupal-assistant-connector/src/settings-page.tsx |
Admin credential management RSC |
drupal-agent/ |
WayFlow (Cinatra’s OAS Flow agent runtime) leaf agent (natural language → node diff) |
dev/drupal-module/cinatra/ |
PHP Drupal module (widget injection) |
docker/drupal/Dockerfile |
Drupal 11 + Drush 13 + mcp_tools image |
scripts/drupal-entrypoint.sh |
Runtime bootstrap (site install, mcp_tools config, API key) |
Auth layers
Section titled “Auth layers”There are two independent Bearer credentials — they must not be confused:
| Credential | Where stored | Who uses it |
|---|---|---|
| Per-instance MCP Bearer token | Nango (the OAuth gateway brokering connector credentials) vault (cinatra-drupal provider, connectionId == instance.id) |
callDrupalMcp / buildDrupalMcpServerTools / getDrupalMcpInstanceStatuses → /_mcp_tools |
Widget apiKey (global) |
drupal_widget_auth config DB |
Browser widget → /api/agents/drupal-content-editor/stream |
The widget apiKey is generated by Cinatra admins at /connectors/cinatra-ai/drupal-assistant-connector/setup. The per-instance MCP token is generated by the Drupal site’s mcp_tools remote-key endpoint (drush mcp-tools:remote-key-create) and entered at /connectors/drupal.
Credential vaulting: the per-instance MCP Bearer token is not stored in the cinatra DB. DrupalInstanceSettings stores nangoConnectionId (== instance.id) + providerConfigKey (cinatra-drupal, generic private-api-bearer Nango template). Every consumer resolves the Bearer header at request time — never from a DB column. The host-side modules (buildDrupalMcpServerTools, getDrupalMcpInstanceStatuses in src/lib/drupal-mcp-connection.ts) call buildBearerAuthHeaderFromNango({ providerConfigKey: "cinatra-drupal", connectionId: instance.nangoConnectionId, label }) directly; the connector’s callDrupalMcp resolves the same builder through DI (getDrupalDeps().buildNangoBearerHeader, bound to buildBearerAuthHeaderFromNango at boot) so the connector package carries no @cinatra-ai/nango-connector code import. saveDrupalInstance is fail-closed: it throws when Nango is unconfigured, and an edit with a blank key preserves the existing vaulted credential (no rotation).
CORS + Bearer gate in agents/[agentSlug]/stream/route.ts
Section titled “CORS + Bearer gate in agents/[agentSlug]/stream/route.ts”The route is a per-slug registry serving both drupal-content-editor and wordpress-content-editor with per-CMS auth helpers. It enforces three gates in order:
- Origin reflect-on-match — request
Originis compared (case-insensitive, trailing-slash-normalised) against everyDrupalInstanceSettings.siteUrl. Mismatch → 403 (no CORS header). Match → origin is reflected intoAccess-Control-Allow-Origin. - Bearer compare —
Authorization: Bearer <key>compared againstreadDrupalWidgetAuthConfig().apiKey(timing-safe). Mismatch → 401 (CORS header present so browser can read the response). - Contract-version gate —
validateAuthInitRequest(src/lib/wp-drupal-contract.ts) rejects an explicitly-present unknowncontractVersionwith a structured 400 the CMS admin can see in the widget panel. Unversioned legacy callers are not blocked.
Do not change the gate order. Origin must be checked before Bearer so that a failed request from an unknown origin returns 403 (no CORS header) rather than leaking the 401 status code to a cross-origin attacker.
What the route actually runs: an LLM turn, not a dispatch
Section titled “What the route actually runs: an LLM turn, not a dispatch”After the gates pass, the route does not dispatch to the WayFlow agent itself. It calls stream from @cinatra-ai/llm with:
- the per-CMS function tool from
createDrupalWidgetChatTool({ context })— the only function tool exposed (the full primitive-handler surface is deliberately not given to the LLM, andskipMcpInjection: trueblocks the Cinatra MCP surface); - the
drupal-widget-chatskill viabuildSkillTools(with a memoized self-heal that re-registers the SKILL.md on prod boots); - a system prompt embedding sanitized request context (
instanceId,nodeId,nodeBundle,nodeStatus,href).
The LLM decides per message whether to answer conversationally (streamed as text SSE frames) or to call drupal_content_editor_run. A tool result containing a real changes array is emitted as a single changes SSE frame. The widget-side SSE wire format is frozen: text / changes / error / done frames only.
A2A dispatch pattern
Section titled “A2A dispatch pattern”The A2A dispatch lives behind the connector handler, split across two layers:
Connector side (drupal-mcp-connector/src/mcp/handlers.ts, drupal_content_editor_run): parses the input, resolves the agent URL (process.env.DRUPAL_CONTENT_EDITOR_A2A_URL ?? "http://localhost:3020"), and calls the host-bound dependency:
const text = await getDrupalDeps().dispatchContentEditor({ agentUrl: a2aUrl, payload: JSON.stringify(input), timeoutMs: 300_000, // aligned with the /chat blocking budget});// connector keeps only the post-processing of the reply text:try { return JSON.parse(stripCodeFences(text));} catch { return { result: text };}Host side (src/lib/host-content-editor-dispatch.ts, dispatchContentEditorViaA2A, shared with the WordPress connector): mints the A2A bearer (buildA2aBearerToken("openai")), opens the external client, sends one blocking text-mode task, and walks task.history:
const client = await createExternalA2AClient({ agentUrl: input.agentUrl, credentials: a2aBearer ? { token: a2aBearer } : undefined, timeoutMs: input.timeoutMs,});const task = await client.sendTask({ message: { role: "user", kind: "message", messageId: randomUUID(), parts: [{ kind: "text", text }] }, configuration: { acceptedOutputModes: ["text"] },});// Walk task.history — NOT task.artifacts (not implemented in WayFlow).// Producers MUST emit role "agent"; "assistant" is accepted on the read side// only because historical runs carry it.const lastAgent = (task.history ?? []).slice().reverse() .find((m) => m?.role === "agent" || m?.role === "assistant");This split keeps the @cinatra-ai/a2a + @cinatra-ai/llm runtime edges host-side so the connector package carries no non-SDK @cinatra-ai/* code dependency.
sendTask is blocking (not streaming). The result arrives in task.history as the conversation turns. task.artifacts is NotImplementedError in WayFlow — never use it.
drupal/mcp_tools tool name quirks
Section titled “drupal/mcp_tools tool name quirks”The drupal/mcp_tools ^1.0@beta module uses non-obvious tool names. The full mapping is in drupal-mcp-connector/AGENTS.md. The most important quirk: there is no get-by-ID tool — drupal_node_get lists recent content via mcp_tools_get_recent_content and filters for the requested node ID client-side (mcp_tools_search_content is unsuitable: it requires a ≥3-character query and does full-text search, not node-ID lookup).
External MCP wiring
Section titled “External MCP wiring”buildExternalMcpServerTools in packages/llm/src/mcp-access.ts concatenates the Drupal MCP server tools alongside WordPress tools. Private/localhost URLs are excluded (agents running outside the network cannot reach them). The probe cache in drupal-mcp-connection.ts is module-level and persists for 2 minutes — use distinct siteUrl values in tests to avoid cache bleed between test cases.
Widget bundle rules
Section titled “Widget bundle rules”Both the WordPress and Drupal widget bundles follow these rules:
- No CDN scripts. Do not load
marked,DOMPurify, or any other library from jsDelivr, unpkg, or any CDN. Supply-chain risk + no SRI without a pre-computed hash. - Inline safe Markdown renderer only.
renderMd()is a self-contained renderer: all user/LLM text goes throughesc()(HTML-entity escape) before being placed in HTML. NoinnerHTMLever receives raw external input. - All backticks in regex inside template literals must be escaped (
\`` not ````). The bundle is a TypeScript template literal string; unescaped backticks close it early. CINATRA_THEMEfor all colors. No hardcoded hex values. Import fromsrc/lib/cinatra-brand.ts.
Drupal dev environment
Section titled “Drupal dev environment”# Start Drupal + MariaDB + WayFlow agentdocker compose --profile drupal up -d
# Drupal UIhttp://localhost:8082admin / cinatra
# MariaDB (from host)localhost:3308 — drupal / drupalThe entrypoint installs Drupal, enables mcp_tools_remote, configures it (enabled + uid + allow_uid1), and generates an API key on first boot. The cinatra module is bind-mounted from dev/drupal-module/cinatra/ for live PHP editing without rebuilding the image.
Adding a new connector that mirrors this pattern
Section titled “Adding a new connector that mirrors this pattern”Follow the WordPress connector as the primary analog (wordpress-mcp-connector/), then apply Drupal-specific adaptations:
- Confirm the CMS has a Streamable HTTP MCP endpoint. Document discovered tool names in a
TOOL-DISCOVERY.md. - Implement
call<CMS>Mcpusing MCP SDKClient+StreamableHTTPClientTransport(not hand-rolled fetch). - Add probe cache +
isPrivateUrlguard in the connection module (reuse fromdrupal-mcp-connection.ts). - Map CMS tool names to Cinatra primitive names in a
TOOLconstant object inhandlers.ts. - Copy the chat route CORS + Bearer pattern from
src/app/api/agents/[agentSlug]/stream/route.ts. - Copy the bundle from the nearest existing bundle, applying the 4-substitution pattern (config bootstrap, context builder, URL, admin link) + event:changes handler.
- Admin page mirrors WordPress widget settings page minus any CMS-specific sections.
Docs content licensed under CC-BY-4.0; embedded code snippets under Apache-2.0.