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; 1–2 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 target1— 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:
- Write the token to a file outside the webroot and VCS (e.g.
../keys/annotations_mcp.key). - Create a Key entity (Admin → Configuration → System → Keys) using the File key provider pointing at that path.
- 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.