Search by

iqual / icms_mcp

iqual

Drupal MCP tools for AI-powered, transactional ICMS content migration and import.

Package info

github.com/iqual-ch/icms_mcp

Type:drupal-module

pkg:composer/iqual/icms_mcp

Statistics

Installs: 136

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-10-16 08:07 UTC

This package is auto-updated.

Last update: 2026-10-19 09:59:57 UTC


README

mcp_server tools that expose ICMS-specific operations to MCP clients (in our case, the iqual drupal-bridge ADK agent on Cloud Run).

What it ships

Tools (src/Plugin/mcp_server/Tool/), advertised under their tool id:

Tool (wire name) Purpose
get_icms_catalog Compact normalized v2 manifest with hash, indexes, capabilities, descriptions, fields, options and existing taxonomy vocabularies.
get_icms_component_contract Full contracts for selected node, paragraph or media bundles, optionally including paragraph children.
get_frontend_theme_files Read-only: the Nuxt frontend's theme layer as it is on this target (app.config.ts, theme/*.css, base/typography.css, utilities/utilities.css, project/*.css, tailwind.css, nuxt.config.ts, package.json), from <repo>/frontend (ICMS_MCP_FRONTEND_DIR overrides), so a design handoff can be diffed against what the project already themes.
validate_pivot Drupal-side validation of an icms-drupal-import-handoff-v1 pivot.
import_pivot Transactional create/update of node + paragraphs + translations. Honours strategy and HITL gate.
import_taxonomy_terms Upsert one vocabulary's terms (uuid/name identity, hierarchy, translations). Step 3, before nodes.
import_menu_links Upsert one menu's links, parents before children; node links resolve via the source-key field, and a parent whose page is missing is kept disabled so its children keep their place. Step 3, after nodes.
lookup_existing_node Idempotency lookup by canonical source URL (matches against the configured source-key field).
rewrite_node_links Post-import pass: {from, to} replacements applied to hrefs in text fields and link-field uris (exact, or by resolved #fragment) on the node, its translations and its paragraphs. Anchors → #<paragraph id>, document links → the stored file's URL.
set_front_page Make an imported, published node the site front page (system.site page.front).
update_node_references Final reference pass: path-addressed write of entity_reference / link fields on the node or a (nested) paragraph ({langcode?, path: [{field?, index}], expect_bundle?, field, value}), translation-aware, saved only on change, one transaction, idempotent. Lets a page imported before the pages it references get its teasers and buttons without a re-import (which would recreate paragraph ids).
read_node Read back a node per language: {status, title, path_alias, fields, paragraphs: [{id, sequence, bundle, translated, status, fields, children}]}, references with their labels, booleans as true/false. For checking what landed against what was sent (job title, publication type, per-language paragraph status).

import_pivot also reports what it created: paragraphs [{sequence, id, revision_id, children: {field: count}}] (top-level layout paragraphs) and media [{sourceUrl, mediaId, bundle, fileUrl, fileUrlAbsolute}] (every media resolved, translations included), which the migrator's post-import link pass consumes. fileUrl is root-relative (/sites/default/files/…) so rewritten links keep working when the site changes host. A media descriptor's caption is written into the media's field_caption when the media type has one and the media has no caption yet. Remote files are stored under the source's own path when it is under a Drupal files directory (public://2023-07/Report.pdf), else under public://icms_mcp/<sha256>.<ext> (a media route's extension comes from the response), and reused by source URL on re-runs. pivot.target.linkedDocuments [{src, title}] — files only linked from text — are created as unplaced document media. A translation's paragraph status is applied per language.

The catalog also carries editorTextStyles: per text format, the CKEditor 5 style plugin's styles (label, tag, classes) and the classes the format allows on text tags — what a source's lead / small / blockquote text can bind to, and what a design handoff's type scale still has to add.

Translations: symmetric by default, detached on demand

A translation in import_pivot's translations[] is written one of three ways, chosen by the spec, never by the field's translatability alone:

Spec Written as Summary paragraphs_mode
structureMatch: true into translations of the shared paragraph entities, by position, after a structure re-check symmetric
mode: "detached" its own single-language paragraph tree on the translation's field rows (ICMS detached content, ICMS-768 — unreleased) detached
neither scalar node fields only, with the reason shared

Detached needs two things on the target, both reported by the catalog: the bundle's layouts field translatable (nodeTypes.<bundle>.paragraphsFieldTranslatable) and the icms_detached_content node base field (detachedContent.supported). The default-language import sets the flag like any other node attribute (node.attributes.icms_detached_content: true); without it the ICMS presave hook would mirror the original's paragraphs back over the translation on the next editorial save, so writeTranslations refuses a detached translation on an unflagged node and says why in reason. A new translation of a detached node is saved once empty before its paragraphs are set, so the ICMS hook has nothing to clone and the imported list is the one that stays. On a target that ships detached content the field is translatable for every page: an attached page still takes the symmetric path, which is why structureMatch decides and not the field.

get_icms_catalog intentionally replaces the old expanded v1 response. Use get_icms_component_contract for details; clients should cache by catalogHash. Component guidance and option definitions ship under resources/, can be overridden through icms_mcp.catalog_descriptions and icms_mcp.catalog_options, and expose alter hooks documented in icms_mcp.api.php.

Behaviour summary

  • Validation runs on every import. import_pivot calls validate_pivot first and refuses to write a contract with any issues.
  • HITL gate. If metadata.review_decision == "review_required", the import returns status: pending_review unless the caller passes approve: true. review_decision == "blocked" always refuses.
  • Strategies (metadata.strategy):
    • skip → if a node with the same idempotence_key exists, return status: skipped without writing.
    • update / skip-or-update → load and rewrite the existing node, replacing its paragraph children (old paragraphs are deleted after the node save commits, so a failure leaves the previous content intact). Cleanup uses paragraph entity IDs rather than historical revision IDs, allowing an update to repair a node that contains a stale paragraph revision reference.
    • fail-if-exists → returns status: conflict if a node with the same idempotence_key already exists.
  • Transactional. All writes run inside a database->startTransaction(). Any throw inside the write path triggers a rollBack() and surfaces as status: error.
  • Audit journal. Every outcome (created / updated / skipped / conflict / error) is logged to the icms_mcp logger channel with idempotence_key, batch_id, run_id in the context so you can correlate in /admin/reports/dblog.
  • Private pivot log. Every payload received by validate_pivot or import_pivot is saved as formatted JSON under private://icms_mcp/pivots. The resulting URI is returned as pivot_log_uri. Disable this diagnostic log with drush state:set icms_mcp.log_pivots 0.

Prerequisites on the target site

Two field machine names are configurable via state (defaults shown):

State key Default Purpose
icms_mcp.source_key_field field_icms_source_key Plain string field (max 512) on every migrated node bundle storing the agent's idempotence_key.
icms_mcp.layouts_field field_icms_paragraphs entity_reference_revisions field on icms_page that holds layout paragraphs.
icms_mcp.identity_fields {} Per bundle, the field that identifies a node that exists before the migration — e.g. {"icms_product": "field_icms_product_id"} for products an ERP feed created. A page whose attributes carry that field updates the matching node instead of creating a twin, and stamps its source key on it. Several matches, or a node another page already claimed, are refused with a warning and the page creates a new node. Reported in the catalog as site.identityFields.

The source-key field is owned by this module — it is pure migration infrastructure, so install/update hooks create it on every node bundle, new bundles get it automatically, and import_pivot recreates it when a config import dropped it. After the module (or an update) first creates it on an environment, export config (drush cex) so the next config import keeps it. The layouts field stays your site's responsibility: it is part of the ICMS content model. If the defaults don't match your install:

ddev drush state:set icms_mcp.source_key_field field_my_source_key
ddev drush state:set icms_mcp.layouts_field field_my_paragraphs
ddev drush state:set icms_mcp.identity_fields '{"icms_product": "field_icms_product_id"}'

validate_pivot and get_icms_catalog both surface a clear error when either field is missing, so misconfiguration is caught before the first import attempt.

Install

The full stack (drupal/mcp_server, simple_oauth, consumers) is pulled in transitively — nothing else to require on the target project.

mcp_server 2.0.0-beta4 or newer is required: beta4 moved tool discovery from src/Plugin/Tool/ to src/Plugin/mcp_server/Tool/, where this module's tools now live. On an older beta the site answers /mcp normally and advertises no tools at all, which the migration agent reports as "Drupal MCP tool not advertised by target site".

ddev composer require 'iqual/icms_mcp'
ddev drush en icms_mcp -y     # prints the OAuth client credentials ONCE
ddev drush cex -y             # consumer, scope, and role are config

Authentication — OAuth 2.1 client_credentials

Everything is provisioned on install (and on drush updb, update 10104):

  1. Signing keys generated under private://simple_oauth/ and wired into simple_oauth.settings as a stream URI — the private file system is never served and never in git, and the exported setting is valid in every environment (DDEV, Upsun mount, production). Skipped when the site already has keys. Install is refused when no file_private_path is configured.
  2. An icms_mcp OAuth scope with ROLE granularity: access tokens carry exactly the icms_mcp role's permissions (access mcp server, use icms_mcp tools), nothing more.
  3. A confidential consumer (client_id: icms_mcp, client_credentials grant, 1h tokens) bound to a passwordless icms_mcp service user — nothing can log in as it; only tokens act through it.
  4. The client_id + client_secret are printed exactly once — paste them into the cockpit connection form. Rotate any time: ddev drush icms-mcp:rotate-secret. Uninstall deletes consumer, scope, user, and role — no standing credential outside the migration window.

Token flow (what the agent does for you):

curl -X POST https://<site>/oauth/token \
  -d 'grant_type=client_credentials&client_id=icms_mcp&client_secret=<secret>&scope=icms_mcp'
# → {access_token, expires_in: 3600}
curl -X POST https://<site>/mcp -H 'Authorization: Bearer <access_token>' ...

Router note (ICMS/Varnish): the path whitelist that passes requests to Drupal must include /mcp AND /oauth — otherwise token requests land in the Nuxt frontend as 404s.

The module ships a route subscriber that allows the oauth2 authentication provider on /mcp (mcp_server's route declares _auth: ['cookie'], and an explicit _auth list excludes even global providers).

Why MCP tools and not a custom REST/JSON:API endpoint?

Reuse + provenance. Every MCP-enabled client (Claude Desktop / Cursor / the ADK McpToolset) discovers the tools automatically, the OAuth + RBAC layer comes from drupal/mcp_server and simple_oauth, and we get the streamable-HTTP transport, schema validation, and permission gating for free.

Moving keys that were generated inside the project

Earlier versions generated the pair at <project>/keys/, inside the repository. If a site still has them there (ddev drush cget simple_oauth.settings), regenerate into the private file system — anything signed with the old pair stops validating, which is the point if they were ever committed:

ddev drush simple-oauth:generate-keys private://simple_oauth
ddev drush cset simple_oauth.settings public_key private://simple_oauth/public.key -y
ddev drush cset simple_oauth.settings private_key private://simple_oauth/private.key -y
ddev drush cex -y
rm -rf drupal/keys   # and purge them from git history if they were pushed