Skip to content

Drupal Connector — Integration Pattern

Documents the Drupal 11 integration pattern. Read alongside mcp-patterns.md and llm-orchestration.md.

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 endpoint

The 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).

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)

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:

  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 (timing-safe). Mismatch → 401 (CORS header present so browser can read the response).
  3. Contract-version gatevalidateAuthInitRequest (src/lib/wp-drupal-contract.ts) rejects an explicitly-present unknown contractVersion with 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, and skipMcpInjection: true blocks the Cinatra MCP surface);
  • the drupal-widget-chat skill via buildSkillTools (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.

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.

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 tooldrupal_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).

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.

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

  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 src/app/api/agents/[agentSlug]/stream/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.

Docs content licensed under CC-BY-4.0; embedded code snippets under Apache-2.0.