Issue #3586150: Add ai_context_site_architecture submodule with MCP resource provider.
Resolves #3586150.
Summary
Adds ai_context_site_architecture — a submodule that auto-harvests
"site behavior contracts" describing what owns each surface on a Drupal
site (entity routes, Views, path aliases, pathauto patterns, ECA rules,
field definitions, content moderation workflows, module state) and
writes them into CCC as drafts for human review before they reach AI
agents.
Plus an optional ai_context_site_architecture_mcp sub-submodule
that exposes the same contracts as MCP resources at
drupal://site/contracts/{contract_id}, so MCP-aware agents (Claude
Code, Cursor, etc.) can fetch them on demand instead of relying solely
on bulk system-prompt injection.
Cross-link: prior art is ai_best_practices issue
#3587321
(static-codebase context-harvester, +93pp accuracy / −62% cost on the
project-context eval). This is the live-bootstrap counterpart wired
into CCC governance.
Eval evidence
Two complementary access channels were measured on a real Drupal CMS
bench (ai-bp-test, 1608 harvested contracts after enabling all
scanners).
Channel 1 — CCC system-prompt injection (Q&A)
15-question dataset, single run per condition, same agent + model + judge across runs. Per-kind tag subscription was the unblock — under a single umbrella tag the CCC selector tie-broke on insertion order and surfaced useless system pseudo-routes ahead of site-specific contracts.
| Metric | Baseline | Per-kind treatment | Δ |
|---|---|---|---|
| Avg score (1–5) | 1.93 | 4.40 | +2.47 |
| Pass rate (≥3.5) | 7% | 87% | +80pp |
13/15 questions improved or held at 5/5 between the umbrella-tag and
per-kind treatments; the three follow-up gap questions (publish
workflow, contact form, category page) all closed once
content_moderation_workflow and module_state contracts were added.
Full dataset + run JSONs: https://gist.github.com/gkastanis/5b2298c87e644b65168a4dfb5074e40a.
Channel 2 — MCP lazy retrieval (coding tasks)
9-task coding eval driven by Claude Code CLI in stdio mode against the new MCP submodule. Snapshot-restore between every invocation so disk state doesn't bias latency. The agent decides if and which contract to fetch — no bulk injection.
The eval was run twice. First a two-arm clean run with the bare resource
template (n=3 per condition, 54 calls). Then, after adding the
list_site_contracts + get_site_contract tools and a server-level
instructions string injected into the initialize response, a
three-arm re-run (n=1 per condition, 27 calls).
After L1+L2+L3 (the design that ships in this MR)
| Metric | Baseline (no MCP) | Naive (no hint) | Hinted (with hint) |
|---|---|---|---|
| Pass rate (gated: retrieval ∧ token-match) | 77.78% | 88.89% | 100.00% |
| Token-match only | 77.78% | 100.00% | 100.00% |
| Retrieval rate | — | 88.89% | 100.00% |
| Mean elapsed | 144s | 92s (−37%) | 85s (−41%) |
| Median elapsed | 134s | 83s (−38%) | 67s (−50%) |
Naive on the new architecture matches what hinted achieved on the old one. Pairwise: naive +11.1pp gated / +22.2pp token-match vs baseline; hinted +22.2pp on both vs baseline. The agent now reliably discovers the contracts without an external system-prompt hint.
Original two-arm clean run (preserved for comparison, n=3)
| Metric | Baseline (clean) | Hinted (clean, no L1+L2+L3) |
|---|---|---|
| Token-match | 85.19% | 88.89% |
| Retrieval | — | 81.48% |
| Mean elapsed | 146s | 114s |
The original ran before list_site_contracts/get_site_contract and
the server-level instructions existed. The naive arm there was 0%
retrieval — Claude Code does not surface resources/templates/list
as a tool, so the agent could not discover URI templates without a
hint. Adding tool plugins + initialize-time instructions closed that
gap.
Pipeline + raw runs: https://gist.github.com/gkastanis/83df7bf3055d3f456e5f01c16a5a70e0.
Architecture (what's in the box)
- Scanners (one per
ScannerInterface):RouteScanner,ViewsScanner,AliasScanner,PathautoPatternScanner,EcaScanner,FieldDefinitionScanner,ContentModerationScanner,ModuleStateScanner. Each yields contract dicts. - Post-processor pipeline:
SurfaceRegistry(conflict detection),EnvelopeBuilder(resource URIs, SHA-256, capability hints),CodeSignalEnricher(regex-scans custom-module hooks/event subscribers and attaches them to relevant contracts). - Writer:
CcciItemWritercreatesai_context_itementities with the JSON contract embedded in the body via a marker pair plus a human-readable markdown projection above.DryRunWriterprints to stdout. - Service:
ContractRepositoryround-trips the JSON envelope on read (get($id),list(),findByPath(),surfaceIndex(),resources(),readResource($uri)). - Drush:
ai-context:harvest-architecture(with--dry-run,--replace) andai-context:export-architecture. The export command writes contracts as JSON to<drupal_root>/.ai/docs/site-architecture/by default (overridable via--output=); intended as the static fallback to MCP — commit to git and reference fromAGENTS.mdfor agents that can't connect to an MCP server. - Optional MCP submodule (
ai_context_site_architecture_mcp) exposes contracts on three complementary surfaces so any MCP-aware agent finds them:ResourceTemplateProviderplugin underdrupal://site/contracts/{contract_id}(URI-based access for clients that prefer resource semantics; self-registers viahook_installto avoid colliding withmcp_server_exampleson the sharedmcp_server.resource_template_providersconfig object).- Two
#[Tool]plugins for clients that discover capabilities viatools/list:list_site_contracts(kind?)returns IDs grouped by kind;get_site_contract(contract_id)returns the JSON envelope. - A
ResponseEventsubscriber that injects a one-paragraph instructions string into theinitializeresponse, so agents are told about the tools + URI shape on connect without needing a system-prompt hint. - All three surfaces gate reads on the existing
'access published ai context'permission.
What's covered (and what isn't)
Scanners walk Drupal's container without filtering origin — contracts
are emitted for core, contrib, and custom modules alike, distinguished
by owner.is_custom: true|false on every contract. Operators can subscribe
per-kind tag (kind:entity_route, kind:view, etc.) to scope what
reaches an agent's prompt.
Code-signal enrichment is narrower in v1: CodeSignalEnricher regex-scans
/modules/custom/ only and attaches detected hooks / event subscribers
/ Views alters / route subscribers to the contracts they affect. Contrib
and core extension code are not scanned. This is a deliberate v1
scope cut, not a position that contrib code is uninteresting:
- Custom code is high-leverage because the model has never seen it; AI agents can already reason about contrib hooks they encountered in training data.
- Regex-scanning the full contrib tree adds harvest wall-clock time and contract envelope size; both have downstream costs (selector token budget, MCP read latency).
- The eval that landed this configuration measured custom-code generation. Debug-class questions ("why is X happening on this surface?") — where contrib signals would add the most value — are not on the current bench.
Surfacing contrib code signals optionally (config flag, default off, predicated on a debug-class eval bench) is tracked as a follow-up issue.
Conventions followed (and where we extended)
We reuse existing CCC primitives wherever possible:
- No new scope plugin. Per the plan, we use the existing
Tagscope fromai_context. Per-kind taxonomy terms (kind:entity_route,kind:view,kind:alias,kind:content_moderation_workflow,kind:module_state, etc.) are auto-created in the existingai_context_tagsvocabulary at harvest time so agents can subscribe selectively without anyone defining a new#[AiContextScope]plugin. - No new permission. The MCP submodule's resource provider and
the two tools all gate on the existing
'access published ai context'permission shipped by the parent module. - No parallel storage. The harvester writes
ai_context_itementities programmatically viaAiContextItem::create()+setScopeValues(), the same path manually-authored items use. The CCC admin UI at/admin/ai/context/itemslists harvested drafts alongside hand-written ones. - Drush namespace. Commands live under
ai-context:*to match the parent module.
Three deliberate extensions, each contained:
- JSON envelope embedded in the markdown body via a marker pair
(
<!--contract-json:{...}:contract-json-->). Editors see clean markdown above the marker; the structured payload below is forContractRepository::get()round-trips. This is the largest semantic departure from "the body is for prose" — happy to discuss alternatives (config entity? separate table?) if it's an obstacle. - Auto-populated taxonomy terms. Per-kind terms in
ai_context_tagsare created by the harvester, not by a human editor. The volume is small (≤10 terms across all kinds) and the names are namespaced (kind:*), so collision risk with human- authored tags is minimal. - Nested MCP submodule (
ai_context_site_architecture_mcp/). Drupal core's submodules are typically siblings, not nested. We nested because this submodule is a strict dependent ofai_context_site_architecture(only useful when its parent is enabled) and we wanted enable/disable to track. Easy to flatten to a sibling layout if the project prefers.
Testing
- 49 PHPUnit tests (Unit + Kernel) across both submodules; all green.
ai_context_site_architecture: 47 tests / 7132 assertions.ai_context_site_architecture_mcp: 2 tests / 75 assertions (resource template + site_contracts tools).
- End-to-end smoke on
ai-bp-test:ddev drush ai-context:harvest-architectureproduces 1608 contracts across 9 kinds. - MCP wire-level smoke via stdio:
initializereturns the server instructions string;resources/templates/listreturns thedrupal://site/contracts/{contract_id}template;resources/readforfield_definition:node:pagereturns the full envelope; bothtools/call mcp__ai-bp-test__list_site_contractsandmcp__ai-bp-test__get_site_contractreturn successfully with the expected shapes.
Scope cuts (explicit non-goals — tracked as follow-ups)
- Negative contracts. Schema reserves
negative: bool; producing them needs a joint design call (TTL/invalidation, lookup primitive) with MCP and agent owners. - UI for managing the harvest job. Drush only in v1; cron is a follow-up.
- Layout Builder / Canvas page contracts.
- Translation handling. Contracts are in the site default language.
Stability note on _mcp submodule
The ai_context_site_architecture_mcp submodule integrates with
drupal/mcp_server, which currently only has a 2.x-dev branch — no
tagged stable release. To run the full test matrix against it, this
MR's composer.json adds drupal/mcp_server: 2.x-dev to require-dev
(alongside drupal/eca and drupal/pathauto, also exercised by
optional-integration tests) and sets minimum-stability: dev /
prefer-stable: true.
Concretely, this means:
- The parent module
ai_context_site_architectureis independently installable and has no soft or hard dep onmcp_server. - The
_mcpsub-submodule tracksmcp_server2.x-dev. Reviewers should treat it as early integration untilmcp_servertags a stable release. If maintainers prefer to land just the parent module now and the submodule oncemcp_serverstabilizes, happy to split.
Follow-ups (filed alongside this MR)
ai_context: negative contracts (TBD)ai_context:view ai context itemspermission default (TBD)ai_context: optionally surface contrib code signals — config flag, default off, predicated on a debug-class eval bench (TBD).mcp_server: per-plugin contribution toinitializeinstructionsstring (TBD). This MR uses the SDK's genericResponseEventas a workaround; an upstream API would be cleaner.ai_best_practices: the four MCP-aware additions torun-evals.py(--mcp-configswitch, stream-json parser, paired baseline/treatment runs,retrieval_checkassertion) will land inai_best_practicesitself as the canonical home for the eval harness — direct successor to closed#3583202("Explore provider-agnostic eval runner"). This MR'sevals/mcp-eval/ships as a local reproducer; the gist is the public surface.
cc @scottfalconer @kepol — flagged on ai_best_practices #3587321 as relevant prior art.