Skip to content

Drupal Connector — Integration Pattern

Documents the Drupal 11 integration introduced in Phase 177. Read alongside mcp-patterns.md and llm-orchestration.md.

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 endpoint
FileRole
src/lib/drupal-api.tsInstance CRUD + DrupalInstanceSettings type
src/lib/drupal-mcp-client.tscallDrupalMcp — MCP SDK Client + StreamableHTTPClientTransport
src/lib/drupal-mcp-connection.tsbuildDrupalMcpServerTools — probe cache + LLM tool registration
src/lib/drupal-widget-auth.tsgenerateDrupalWidgetAuthConfig — UUID-pair widget API key
packages/connector-drupal/src/mcp/handlers.ts7 MCP primitive handlers
src/app/api/drupal-widget/chat/route.tsCORS + Bearer gate + A2A blocking dispatch + SSE
src/app/api/drupal-widget/bundle.js/route.tsIIFE bundle served as JS
src/app/settings/connectors/drupal-widget/page.tsxAdmin 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/DockerfileDrupal 11 + Drush 13 + mcp_tools image
scripts/drupal-entrypoint.shRuntime bootstrap (site install, mcp_tools config, API key)

There are two independent Bearer credentials — they must not be confused:

CredentialWhere storedWho uses it
Per-instance MCP Bearer tokenNango vault (cinatra-drupal provider, connectionId == instance.id) — Phase 302callDrupalMcp / buildDrupalMcpServerTools / getDrupalMcpInstanceStatuses/_mcp_tools
Widget apiKey (global)drupal_widget_auth config DBBrowser 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/.

The chat endpoint enforces two gates in order:

  1. Origin reflect-on-match — request Origin is compared (case-insensitive, trailing-slash-normalised) against every DrupalInstanceSettings.siteUrl. Mismatch → 403 (no CORS header). Match → origin is reflected into Access-Control-Allow-Origin.
  2. Bearer compareAuthorization: Bearer <key> compared against readDrupalWidgetAuthConfig().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.

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.

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 toolmcp_tools_search_content is used as a proxy for drupal_node_get.

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.

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 through esc() (HTML-entity escape) before being placed in HTML. No innerHTML ever 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_THEME for all colors. No hardcoded hex values. Import from src/lib/cinatra-brand.ts.
Terminal window
# Start Drupal + MariaDB + WayFlow agent
docker compose --profile drupal up -d
# Drupal UI
http://localhost:8082
admin / cinatra
# MariaDB (from host)
localhost:3308 drupal / drupal

The 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:

  1. Confirm the CMS has a Streamable HTTP MCP endpoint. Document discovered tool names in a TOOL-DISCOVERY.md.
  2. Implement call<CMS>Mcp using MCP SDK Client + StreamableHTTPClientTransport (not hand-rolled fetch).
  3. Add probe cache + isPrivateUrl guard in the connection module (reuse from drupal-mcp-connection.ts).
  4. Map CMS tool names to Cinatra primitive names in a TOOL constant object in handlers.ts.
  5. Copy the chat route CORS + Bearer pattern from drupal-widget/chat/route.ts.
  6. Copy the bundle from the nearest existing bundle, applying the 4-substitution pattern (config bootstrap, context builder, URL, admin link) + event:changes handler.
  7. Admin page mirrors WordPress widget settings page minus any CMS-specific sections.