Skip to content

Annotations Context — Developer Reference

Developer-focused reference. For module overview, feature list, and permissions see README.md.


ContextAssembler (annotations_context.assembler)

The central service. Builds a structured PHP array from annotation data. All other features in this module and its consumers derive from this payload.

use Drupal\annotations_context\ContextAssembler;

$payload = $assembler->assemble();                                 // all targets, all types
$payload = $assembler->assemble(['entity_type' => 'node']);        // one entity type
$payload = $assembler->assemble(['target_id' => 'node__article']); // one target
$payload = $assembler->assemble(['types' => ['editorial']]);       // explicit type filter
$payload = $assembler->assemble(['ref_depth' => 1]);               // follow ER fields one hop
$payload = $assembler->assemble(['account' => $currentUser]);      // filter by user permissions
$payload = $assembler->assemble(['role' => 'editor']);             // simulate a role
$payload = $assembler->assemble(['inc_refs' => TRUE]);             // add reverse ER sources

All options are optional and combine freely.

Options

Option Type Default Description
entity_type string\|null null Limit to targets of this entity type.
target_id string\|null null Limit to a single target by machine name (e.g. node__article).
types string[]\|null null (all) Explicit list of annotation type IDs to include.
ref_depth int 0 Entity-reference traversal depth. 0 = no traversal; 12 follows linked targets.
role string\|null null Simulate context as this Drupal role — only types that role can consume are included. Takes precedence over account.
account AccountInterface\|null null Filter to types the given account can view via its combined role permissions. Accounts with administer annotations bypass filtering.
inc_meta bool false Add type, cardinality, and description to each field entry. Useful for AI context; noisy for human review.
inc_refs bool false Add an incoming_refs key to each target listing annotation targets that reference it via entity-reference fields. Flat only — no recursive reverse traversal.

Role and account filtering

Use role to simulate what a role sees without impersonating a user — useful for previews and testing:

$payload = $assembler->assemble(['role' => 'content_editor']);

Use account for real current-user context in live features. Accounts with administer annotations bypass all type filtering:

$payload = $assembler->assemble(['account' => $this->currentUser]);

role takes precedence when both are supplied.

Payload structure

[
  'groups' => [
    'node' => [
      'entity_type' => 'node',
      'label'       => 'Content types',
      'targets'     => [
        'node__article' => [
          'id'          => 'node__article',
          'label'       => 'Article',
          'entity_type' => 'node',
          'bundle'      => 'article',
          'annotations' => [
            'editorial' => ['label' => 'Editorial', 'value' => '...'],
            'rules'     => ['label' => 'Rules',     'value' => '...'],
          ],
          'fields' => [
            'body' => [
              'label'       => 'Body',
              'annotations' => ['editorial' => ['label' => 'Editorial', 'value' => '...']],
              // 'meta' key present when inc_meta = TRUE:
              'meta' => ['type' => 'text_long', 'cardinality' => 'single value', 'description' => '...'],
            ],
          ],
          'references'    => [...], // only present when ref_depth > 0
          'incoming_refs' => [      // only present when inc_refs = TRUE
            'media__image' => [
              'label'      => 'Image',
              'via_fields' => ['field_featured_image'],
            ],
          ],
        ],
      ],
    ],
  ],
  'meta' => [
    'generated_at' => '2026-04-20T12:00:00+00:00',
    'ref_depth'    => 0,
    'inc_refs'     => FALSE,
    'target_count' => 12,
  ],
]

Only non-empty annotation values are included. Targets with no matching annotations are omitted when type-filtering is active.

HTML normalization: All string values in the payload are passed through flattenHtml() before being added. This strips markup, preserves links as text (url), decodes HTML entities, and collapses whitespace. Normalization happens at read time so all consumers — HTML preview, markdown, JSON API, MCP — receive clean text.

Cache metadata from alter implementations

If your code produces a cacheable page from an assembled payload, merge alter-contributed cache requirements:

$payload = $assembler->assemble($options);
$assembler->getLastCacheableMetadata()->applyTo($build);

ContextPreviewController does this automatically.


ContextRenderer (annotations_context.renderer)

Renders the payload to a UTF-8 markdown string. Stateless — no Drupal services involved. Safe for file download; values are not HTML-escaped (markdown is plain text).

$markdown = $renderer->render($payload);

ContextHtmlRenderer (annotations_context.html_renderer)

Renders the payload to a Drupal render array. All annotation values are escaped via Html::escape(). Uses details/summary collapsible cards.

$build = $htmlRenderer->render($payload);
// Return directly from a controller.

Writing a custom renderer

A renderer just consumes the plain PHP array — no base class required.

class MyJsonRenderer {
  public function render(array $payload): string {
    $output = [];
    foreach ($payload['groups'] as $group) {
      foreach ($group['targets'] as $target) {
        $output[] = [
          'id'          => $target['id'],
          'annotations' => $target['annotations'],
          'fields'      => $target['fields'],
        ];
      }
    }
    return json_encode($output, JSON_PRETTY_PRINT);
  }
}

Security: Always escape annotation values when producing HTML output. Although ContextAssembler strips HTML markup from values at read time, the resulting plain text must still be escaped (via Html::escape() or #plain_text) before insertion into HTML.


Entity reference traversal

Set ref_depth to follow entity reference fields into referenced targets:

  • 0 (default) — no traversal; only the directly annotated target
  • 1 — one hop (e.g. Article → referenced Media)
  • 2 — two hops (recommended maximum; depth 3+ rarely adds useful signal and can produce very large payloads)

Each referenced target is assembled in full and nested under references → field name → target ID. Cycle detection prevents the same target appearing twice in a payload.

Incoming references

Set inc_refs => TRUE (or ?inc_refs=1 on HTTP endpoints) to surface reverse relationships — which annotation targets reference a given target. Useful for leaf entities.

$payload = $assembler->assemble([
  'target_id' => 'media__image',
  'inc_refs'  => TRUE,
]);

Each target entry gains an incoming_refs key:

'incoming_refs' => [
  'node__article' => [
    'label'      => 'Article',
    'via_fields' => ['field_featured_image'],
  ],
  'node__landing_page' => [
    'label'      => 'Landing page',
    'via_fields' => ['field_hero_media', 'field_gallery'],
  ],
],

via_fields is always an array. Only ER fields in the source target's annotation scope are considered, matching the forward traversal behavior. Reverse traversal is flat — incoming sources are not themselves expanded.


Extending the payload

ContextAssembler::assemble() invokes hook_annotations_context_alter() at the end of every assembly call. Use it to append, remove, or reshape payload sections.

use Drupal\Core\Cache\CacheableMetadata;

function mymodule_annotations_context_alter(array &$payload, array $options, CacheableMetadata &$cacheableMetadata): void {
  // Any top-level key not named 'groups' or 'meta' is yours.
  $payload['my_section'] = [
    'setting_a' => 'value',
    'setting_b' => TRUE,
  ];

  $cacheableMetadata->addCacheTags(['mymodule_data_list']);
  $cacheableMetadata->addCacheContexts(['user.roles']);
}

The $options argument is the same array passed to assemble() — use it to conditionally modify the payload based on filters the caller applied.

Callers that produce cacheable output must merge the metadata:

$payload = $assembler->assemble($options);
$assembler->getLastCacheableMetadata()->applyTo($build);

JSON API endpoint

GET /api/annotations/{target_id} returns the assembled context payload as JSON.

GET /api/annotations/node__article
GET /api/annotations/node__article?ref_depth=1
GET /api/annotations/node__article?inc_meta=1
GET /api/annotations/media__image?inc_refs=1

Query parameters:

Parameter Values Default Description
ref_depth 0, 1, 2 0 Entity reference traversal depth.
inc_meta 1 off Include field type, cardinality, and description.
inc_refs 1 off Add incoming_refs to each target.

Responses: 200 (full assembler payload) or 404 {"error": "Annotation target not found."}.

Caching: CacheableJsonResponse tagged with annotation_list, annotation_target_list, and annotation_type_list. Contexts: user.permissions, url.query_args, languages:language_interface (plus languages:content on multilingual sites).

Response shape:

{
  "groups": {
    "node": {
      "entity_type": "node",
      "label": "Content types",
      "targets": {
        "node__article": {
          "id": "node__article",
          "label": "Article",
          "entity_type": "node",
          "bundle": "article",
          "annotations": {
            "editorial": { "label": "Editorial", "value": "..." }
          },
          "fields": {
            "body": {
              "label": "Body",
              "annotations": {
                "editorial": { "label": "Editorial", "value": "..." }
              }
            }
          }
        }
      }
    }
  },
  "meta": {
    "generated_at": "2026-04-21T12:00:00+01:00",
    "ref_depth": 0,
    "inc_refs": false,
    "target_count": 1
  }
}

MCP endpoint

POST /api/annotations/mcp implements the MCP Streamable HTTP transport (2025-03-26 spec). Each annotation_target is exposed as an MCP resource addressed by annotation://target/{target_id}.

Supported methods:

Method Description
initialize Capability handshake; negotiates protocol version (2025-03-26 or 2024-11-05).
resources/list Returns all annotation targets as MCP resources.
resources/read Returns assembled context for one target as markdown (text/plain).
ping Keep-alive; returns empty result object.
notifications/* Acknowledged with 202 No Content; no response body.

Query parameters on resources/read URIs:

Parameter Values Default Description
ref_depth 0, 1, 2 0 Entity reference traversal depth.
inc_meta 1 off Include field type, cardinality, and description.
inc_refs 1 off Add incoming_refs to each target.

Example URI: annotation://target/node__article?ref_depth=1&inc_meta=1

Type filtering: resources/read only returns annotation types where the annotations_context.in_ai_context third-party setting is TRUE. Set via the annotation type edit form — default is off.

Auth

Bearer token (headless clients): Generate a key at /admin/config/annotations/context/mcp. To keep it out of config exports and the database, use the Key module file provider:

  1. Write the token to a file outside the webroot and VCS (e.g. ../keys/annotations_mcp.key).
  2. Create a Key entity (Admin → Configuration → System → Keys) using the File key provider pointing at that path.
  3. Add a config override in settings.local.php (gitignored):

php $config['annotations_context.settings']['mcp_api_key'] = trim(file_get_contents('../keys/annotations_mcp.key'));

Pass the key as Authorization: Bearer <key>. Bearer token holders bypass per-role type filtering and see all opted-in types.

Session auth: Any authenticated user with view annotations context or administer annotations can call the endpoint using a Drupal session cookie. Type visibility is filtered by consume {type} annotations permissions.

Claude Code setup

Add the server to .claude/settings.local.json (not settings.json — the key must stay out of version control):

{
  "mcpServers": {
    "annotations": {
      "type": "http",
      "url": "https://dotdev.ddev.site/api/annotations/mcp",
      "headers": {
        "Authorization": "Bearer <your-key>"
      }
    }
  }
}

Restart Claude Code after saving. The resources/list and resources/read tools will be available in session.

Claude Code skill

A Claude Code skill ships with this module at .claude/skills/annotations-context/. Copy it into any project that has the MCP endpoint configured:

cp -r annotations/modules/annotations_context/.claude/skills/annotations-context \
  .claude/skills/

The skill is self-contained and works independently of this module's codebase.