iqual / icms_mcp
Drupal MCP tools for AI-powered, transactional ICMS content migration and import.
Requires
- php: >=8.3
- drupal/consumers: ^1.24
- drupal/core: ^10.3 || ^11
- drupal/mcp_server: ^2.0.0-beta4
- drupal/paragraphs: ^1.20
- drupal/paragraphs_blokkli: ^1.3 || ^2.0@beta
- drupal/simple_oauth: ^6.1
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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_pivotcallsvalidate_pivotfirst and refuses to write a contract with any issues. - HITL gate. If
metadata.review_decision == "review_required", the import returnsstatus: pending_reviewunless the caller passesapprove: true.review_decision == "blocked"always refuses. - Strategies (
metadata.strategy):skip→ if a node with the sameidempotence_keyexists, returnstatus: skippedwithout 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→ returnsstatus: conflictif a node with the sameidempotence_keyalready exists.
- Transactional. All writes run inside a
database->startTransaction(). Any throw inside the write path triggers arollBack()and surfaces asstatus: error. - Audit journal. Every outcome (
created/updated/skipped/conflict/error) is logged to theicms_mcplogger channel withidempotence_key,batch_id,run_idin the context so you can correlate in/admin/reports/dblog. - Private pivot log. Every payload received by
validate_pivotorimport_pivotis saved as formatted JSON underprivate://icms_mcp/pivots. The resulting URI is returned aspivot_log_uri. Disable this diagnostic log withdrush 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):
- Signing keys generated under
private://simple_oauth/and wired intosimple_oauth.settingsas 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 nofile_private_pathis configured. - An
icms_mcpOAuth scope with ROLE granularity: access tokens carry exactly theicms_mcprole's permissions (access mcp server,use icms_mcp tools), nothing more. - A confidential consumer (
client_id: icms_mcp, client_credentials grant, 1h tokens) bound to a passwordlessicms_mcpservice user — nothing can log in as it; only tokens act through it. - The
client_id+client_secretare 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