Drupal Connector — Integration Pattern
Documents the Drupal 11 integration introduced in Phase 177. Read alongside mcp-patterns.md and llm-orchestration.md.
Architecture overview
Section titled “Architecture overview”Drupal page (browser) └─ cinatra_widget PHP module injects bundle + drupalSettings └─ /api/drupal-widget/bundle.js (IIFE, Shadow DOM widget) └─ POST /api/drupal-widget/chat [Bearer + CORS] └─ createExternalA2AClient → drupal-content-editor (WayFlow, :3020) └─ drupal_node_* MCP primitives → /api/mcp └─ callDrupalMcp → <siteUrl>/_mcp_tools [Bearer] └─ drupal/mcp_tools Streamable HTTP endpointKey files
Section titled “Key files”| File | Role |
|---|---|
src/lib/drupal-api.ts | Instance CRUD + DrupalInstanceSettings type |
src/lib/drupal-mcp-client.ts | callDrupalMcp — MCP SDK Client + StreamableHTTPClientTransport |
src/lib/drupal-mcp-connection.ts | buildDrupalMcpServerTools — probe cache + LLM tool registration |
src/lib/drupal-widget-auth.ts | generateDrupalWidgetAuthConfig — UUID-pair widget API key |
packages/connector-drupal/src/mcp/handlers.ts | 7 MCP primitive handlers |
src/app/api/drupal-widget/chat/route.ts | CORS + Bearer gate + A2A blocking dispatch + SSE |
src/app/api/drupal-widget/bundle.js/route.ts | IIFE bundle served as JS |
src/app/settings/connectors/drupal-widget/page.tsx | Admin credential management RSC |
agents/drupal-content-editor/ | WayFlow leaf agent (natural language → node diff) |
drupal-module/cinatra_widget/ | 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 vault (cinatra-drupal provider, connectionId == instance.id) — Phase 302 | callDrupalMcp / buildDrupalMcpServerTools / getDrupalMcpInstanceStatuses → /_mcp_tools |
Widget apiKey (global) | drupal_widget_auth config DB | Browser widget → /api/drupal-widget/chat |
The widget apiKey is generated by Cinatra admins at /administration/assistants/drupal-widget. 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.
Phase 302 (credential vaulting): the per-instance MCP Bearer token is no longer stored in the cinatra DB. DrupalInstanceSettings dropped mcpApiKey and gained nangoConnectionId (== instance.id) + providerConfigKey (cinatra-drupal, generic private-api-bearer Nango template). Every consumer (callDrupalMcp, buildDrupalMcpServerTools, getDrupalMcpInstanceStatuses) resolves the Bearer header at request time via buildBearerAuthHeaderFromNango({ providerConfigKey: "cinatra-drupal", connectionId: instance.nangoConnectionId, label }) — never from a DB column. saveDrupalInstance is fail-closed: it throws when Nango is unconfigured, and an edit with a blank key preserves the existing vaulted credential (no rotation). One-shot migration: scripts/302-migrate-apify-drupal-to-nango.mjs. See .planning/phases/302-apify-drupal-nango-mcp-vault/.
CORS + Bearer gate in chat/route.ts
Section titled “CORS + Bearer gate in chat/route.ts”The chat endpoint enforces two 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. Mismatch → 401 (CORS header present so browser can read the response).
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.
A2A dispatch pattern
Section titled “A2A dispatch pattern”const a2aClient = await createExternalA2AClient({ agentUrl: process.env.DRUPAL_CONTENT_EDITOR_A2A_URL ?? "http://localhost:3020", credentials: a2aBearer ? { token: a2aBearer } : undefined, timeoutMs: 600_000,});const task = await a2aClient.sendTask({ ... });// Walk task.history — NOT task.artifacts (not implemented in WayFlow)const history = (task as any).history ?? [];const lastAssistant = history.slice().reverse().find((m: any) => m?.role === "assistant");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 packages/connector-drupal/AGENTS.md. The most important quirk: there is no get-by-ID tool — mcp_tools_search_content is used as a proxy for drupal_node_get.
External MCP wiring
Section titled “External MCP wiring”buildExternalMcpServerTools in packages/llm-orchestration/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 — enforced after the Phase 177 code review:
- 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_widget module is bind-mounted from drupal-module/cinatra_widget/ 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 (packages/connector-wordpress/), 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
drupal-widget/chat/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.