| name | Magento Bricklayer (contributing) |
| description | Orientation for an AI agent working ON the magento-bricklayer source — how the codebase is laid out, how tools/prompts/resources are registered and auto-discovered, how to add or change one, the QA gates, and the design rule every change is judged against. This is for modifying Bricklayer itself, not for using it against a Magento store (that is the README + wiki). |
Magento Bricklayer — Working on the Codebase
Bricklayer is a Symfony Console application + MCP (Model Context Protocol) server that
exposes a running Magento 2 installation to AI coding agents. It is a standalone Composer
library (Inchoo\MagentoBricklayer), not a Magento module — it has no module.xml,
registers nothing in Magento's DI graph, and bootstraps Magento the way a CLI tool does.
This file orients an agent that is editing Bricklayer's own source. If you are looking
for how to use Bricklayer's tools against a Magento store, that lives in README.md and
docs/wiki/ — not here.
Rule zero: never hardcode a count
Bricklayer's most important internal discipline is zero drift: every count (tools,
prompts, resources, categories, gated tools…) is derived from source at runtime, never
maintained by hand. ToolRegistry::count() feeds the server instructions; ConfigValidator::getKnownTools()
and ConfigInitializer::discoverConfigurableTools() discover the tool set by reflecting
#[McpTool] attributes and scanning requireToolEnabled() call sites. No hand-written list
exists, so nothing can fall out of sync with the code.
This applies to you too. Don't write a tool/prompt/resource/category count into this
file or anywhere in the docs — it isn't needed to work on the code and it goes stale on the
next change. Describe structure, not quantity. If you genuinely need a current number (e.g.
asserting one in a test, or touching server-instruction logic), derive it:
grep -rl '#\[McpTool' src/Mcp/Tool --include='*.php' | wc -l
grep -rho '#\[McpTool' src/Mcp/Tool --include='*.php' | wc -l
grep -rc "'hidden' => true" src/Mcp/Tool/*.php
grep -rho '#\[McpPrompt' src/Mcp/Prompt | wc -l
grep -rho '#\[McpResource\b' src/Mcp/Resource | wc -l
grep -rhoE "requireToolEnabled\(\s*'[a-z0-9-]+'" src/Mcp/Tool | sort -u | wc -l
Grep is approximate for attributes. PHP 8 attributes can span multiple lines and a bare
grep '#[McpTool' will also match the one inside a docblock comment in
src/Mcp/Prompt/AbstractPrompt.php (it has no real prompt methods — it's a transparent
base). For an exact count, parse with attribute-awareness (walk the parens, skip comment
lines) rather than counting raw matches. When in doubt, reflect.
Source map
magento-bricklayer/
├── bin/
│ ├── bricklayer # Symfony Console entry (config:set, init, install, mcp, inspect, update, verify)
│ ├── bricklayer-mcp # bare MCP server entry (no Console overhead) — what agents launch
│ └── bricklayer-mcp-docker # bash wrapper: auto-detects the PHP container, runs the server inside it
├── src/
│ ├── Application.php # registers the console commands
│ ├── Bootstrap/ # MagentoBootstrap (init + staleness), MagentoDetector, AreaEmulator
│ ├── Command/ # one class per CLI command + AbstractBricklayerCommand base
│ ├── Config/ # ConfigLoader, ConfigValidator, ConfigInitializer, EnvironmentResolver,
│ │ # IteratesToolPhpFiles (shared tool-file walker)
│ ├── Exception/ # Bricklayer / Bootstrap / Configuration / MagentoNotFound
│ ├── Guidelines/ # GuidelinesCompiler (generates CLAUDE.md/.cursorrules/…),
│ │ # LocalOverrideHelper (.bricklayer/ frontmatter), ToolScanner (reflection)
│ ├── Integration/ # McpConfigWriter (writes .mcp.json / .idea/mcp.json)
│ ├── Support/ # CollectsMarkdownFiles (namespace-neutral .md walker)
│ └── Mcp/
│ ├── McpServerFactory.php # builds the server: dynamic instructions, ServerCapabilities, pagination=200
│ ├── Tool/ # ← the bulk of the surface: one class per domain
│ │ ├── *Tools.php # one class per domain (CatalogTools, OrderTools, …)
│ │ ├── ToolRegistry.php # singleton: TOOL_GROUPS map + reflection scan + count()
│ │ ├── TimeUnits.php # relative-time strings → seconds/cutoff/hours
│ │ ├── Concern/ # shared tool traits (see "Trait toolbox")
│ │ └── Diagnostic/ # ExceptionParser for diagnose-error
│ ├── Prompt/ # #[McpPrompt] classes + AbstractPrompt base
│ └── Resource/ # #[McpResource]/#[McpResourceTemplate] classes + FileLoaderTrait
├── config/
│ ├── guidelines/ # markdown guideline corpus, grouped: areas/ core/ database/ ecosystem/ modules/ patterns/
│ └── skills/ # SKILL.md knowledge modules served by development-context
├── docs/wiki/ # human-facing developer docs (git subtree, Makefile push/pull/diff) — NOT agent-facing
├── tests/Unit/ # mirrors src/ layout; *Test.php; PHPUnit 10/11
├── composer.json # deps + QA scripts; PSR-4 Inchoo\MagentoBricklayer\ => src/
├── phpstan.neon.dist # level 8 over src + tests
├── phpunit.xml.dist
├── README.md # human/usage docs (large)
└── SKILL.md # this file
Every PHP file starts with the same header and declare(strict_types=1):
<?php
declare(strict_types=1);
namespace Inchoo\MagentoBricklayer\Mcp\Tool;
How registration and discovery work
There is no manual wiring. The MCP SDK's attribute discoverer scans the configured
directories and registers every method carrying an MCP attribute. Three attributes, imported
from Mcp\Capability\Attribute\*:
| Attribute | Where | Registers |
|---|
#[McpTool(name, description, meta?)] | src/Mcp/Tool/*.php | a callable tool |
#[McpPrompt(...)] | src/Mcp/Prompt/*.php | a prompt template |
#[McpResource(...)] / #[McpResourceTemplate(...)] | src/Mcp/Resource/*.php | a static / templated resource |
Two consequences a contributor must internalize:
- The discoverer only indexes methods declared directly on a concrete class. That is why
AbstractPrompt can be an abstract class with shared formatting helpers and stay invisible
to prompt discovery — it has no #[McpPrompt] methods of its own.
- Visibility is just metadata. A tool with
meta: ['hidden' => true] is omitted from
tools/list but remains fully callable by name. Visible (tier-1) tools simply omit the
meta argument. search-tools is the gateway agents use to find hidden ones.
ToolRegistry (src/Mcp/Tool/ToolRegistry.php) is a separate, reflection-driven index used
for grouping and counting. It holds TOOL_GROUPS — a map of group name → tool classes — and
exposes all() / count() / getToolGroups(). The current groups are: introspection,
catalog, orders, customers, database, logs, diagnostic, graphql, development,
code-generation, context. search-tools filters on these.
Adding or changing a tool
Tools are grouped by domain into a single class (CatalogTools, OrderTools, …). To add one:
- Pick the right class for the domain. Add a
public function returning array.
- Declare the attribute. Visible tool:
#[McpTool(
name: 'product-get',
description: 'Get product by SKU. Use fields to limit response.'
)]
public function getProduct(string $sku = '', int $storeId = 0, string $fields = ''): array
Hidden (tier-2) tool — add the meta arg:
#[McpTool(
name: 'product-list',
description: '…',
meta: ['hidden' => true]
)]
Keep descriptions ≤ ~80 words and front-load a "when to use" trigger — agents only
reliably reach for tools whose description tells them when to.
- Apply the right guards, in order, at the top of the method. This order is a convention
the whole codebase follows:
if ($error = $this->requireMagento()) return $error;
if ($error = $this->requireToolEnabled('foo')) return $error;
if ($error = $this->requireNonProduction('foo')) return $error;
- Pure read/introspection (plugin-list, di-configuration, check-class…): only
requireMagento(). No config gate — reading resolved DI or a plugin chain is non-destructive.
- Write or cost-sensitive (creates/updates,
database-query, log, diagnose-performance,
code-runner): add requireToolEnabled('foo'). The act of adding this call is what makes
the tool config-gated — ConfigInitializer discovers it by scanning for the call site, so
no list anywhere needs updating.
- Destructive (deletes, refunds, codegen): also add
requireNonProduction('foo') and
add the tool name to ConfigInitializer::DESTRUCTIVE_TOOLS so it ships enabled: false.
- Mix in the traits you need (see below) via
use at class scope.
- Register the class in
ToolRegistry::TOOL_GROUPS under the appropriate group, so
search-tools can group/discover it.
- Write a
*Test.php under the mirrored tests/Unit/Tool/ path.
For consolidated tools (one tool, many behaviours) follow the existing pattern: a routing
parameter + a match expression — e.g. graphql-inspect target=…, system-status check=…,
log action=…, application-info include=…. Prefer consolidating over adding a new visible
tool; the visible surface is deliberately small.
Trait toolbox (src/Mcp/Tool/Concern/)
The Concern traits provide the cross-cutting behaviour. Reach for these instead of
re-implementing — consistency here is enforced by review.
| Trait | Provides |
|---|
RequiresMagento | requireMagento() — lazy bootstrap check + auto-reinit when sentinel files change |
ChecksConfig | requireToolEnabled(), requireNonProduction(), isProductionMode(); config staleness auto-reload via getConfigLoader() |
RespondsWithErrors | the canonical error envelope (this replaced the old ToolException, now removed) |
PaginatesResults | the canonical paginated list envelope (has_more, etc.) — use on every list tool |
FiltersFields | the optional fields whitelist parameter |
MasksSensitiveConfig | single source of truth for the sensitive-prefix list + path masking — use anywhere config values can surface |
ReadsLogFiles | tail-reading, entry parsing, time-cutoff, truncation; honors log.max_lines |
RequiresValidVerbosity | validates the minimal/standard/detailed enum |
ResolvesPackagePaths | package-root / Magento-root constructor logic (for testability) |
SecureArea | runs admin-context operations inside isSecureArea=true and unwinds after |
Cross-namespace helpers worth knowing: TimeUnits (Mcp/Tool/), CollectsMarkdownFiles
(Support/), IteratesToolPhpFiles (Config/), AbstractBricklayerCommand (Command/),
AbstractPrompt (Mcp/Prompt/), LocalOverrideHelper (Guidelines/).
The other MCP subsystems (where to edit)
Prompts (src/Mcp/Prompt/) — #[McpPrompt] methods on concrete classes grouped by domain
(ModulePrompts, PluginPrompts, …). AbstractPrompt is the shared text-formatting base and
must stay free of #[McpPrompt] methods. To add a prompt, add a method to the right class.
Resources (src/Mcp/Resource/) — #[McpResource] for fixed resources,
#[McpResourceTemplate] for templated ones. FileLoaderTrait + Support\CollectsMarkdownFiles
power the file-backed ones. The guidelines and skills resources auto-discover .md files
under config/guidelines/ and config/skills/ — so adding a guideline or skill file needs
no code change, only a new markdown file.
Guidelines (config/guidelines/) — the markdown corpus compiled into agent files
(CLAUDE.md, .cursorrules, …) by GuidelinesCompiler. Grouped into areas/ core/ database/ ecosystem/ modules/ patterns/. Pure content; edit the markdown.
Skills (config/skills/) — SKILL.md knowledge modules served on demand by the
development-context tool.
Context categories — the development-context map lives in CATEGORY_MAP in
src/Mcp/Tool/ContextTools.php. Each key maps to {skills[], guidelines[], description, group}.
On a call it loads the mapped files from config/skills/ + config/guidelines/, strips SKILL.md
YAML frontmatter, prefers any .bricklayer/ override, and appends category-specific
_next_steps. Add a category by adding a map entry plus the referenced markdown (the full
override contract is below).
Project-local overrides (.bricklayer/)
A .bricklayer/ directory at the Magento root (not in the package) lets a project extend
bundled content without forking. The path is the contract — nothing is registered in
.bricklayer.json. GuidelinesCompiler reads it, ContextTools/SearchTools resolve it, and
all of it flows through Guidelines\LocalOverrideHelper (frontmatter parse/strip).
| Path | Effect |
|---|
.bricklayer/project-context.md | appended to every generated agent file under ## Project-Specific Context |
.bricklayer/decision-matrix.md | extra rows merged into the "Before Modifying Magento Code" table (malformed lines skipped) |
.bricklayer/guidelines/<path>.md | matches a bundled path → override; no match → new section |
.bricklayer/skills/<category>/SKILL.md | matches a bundled category → override; else a new local-only category callable via development-context category=<dir> |
Local SKILL.md may carry YAML frontmatter (name:/description:), stripped before content
reaches agents and used as display metadata. GuidelinesCompiler tracks appliedLocalOverrides[]
vs appliedLocalAdditions[]; update reports both. The testability seam for this whole
subsystem is the optional $magentoRoot/$packageRoot on the GuidelinesCompiler,
ContextTools, and SearchTools constructors — that is what the tests drive.
CLI commands (src/Command/)
Registered in Application.php; AbstractBricklayerCommand provides shared Magento-root
resolution + IO.
| Command | Class | Responsibility |
|---|
install | InstallCommand | detect env, write .mcp.json + agent guideline files (CLAUDE.md / .cursorrules / .idea/mcp.json / …) + .bricklayer.json, then run verify |
init | InitCommand | write .bricklayer.json with deploy-mode-aware defaults; create the .bricklayer/ dir |
config:set | ConfigSetCommand | read/write a single .bricklayer.json value; interactive picker when run bare; warns on an ineffective key or a shadowing BRICKLAYER_* env var |
mcp | McpServerCommand | run the server over stdio (honors --magento-root even without pcntl); bin/bricklayer-mcp is the bare-script equivalent |
inspect | InspectCommand | print Magento version/edition/mode/module count/store hierarchy; --json, --no-bootstrap |
update | UpdateCommand | regenerate agent files from bundled + .bricklayer/ content; report applied overrides |
verify | VerifyCommand | health check — each check is a private check* method (bootstrap, deploy mode, server build, agent configs, .bricklayer.json, PsySH, DB, log dir, code-runner gating, diagnose-error) |
ConfigSetCommand's value parser: true/false → bool, null → null, numeric → int/float,
[…]/{…} → JSON, else string. Negative integers need the -- separator (a Symfony Console
quirk), e.g. config:set -- tools.x.max_rows -1.
Environment detection & config writing (Bootstrap/, Integration/, the docker wrapper)
install and the config writers target six environments. MagentoDetector::getEnvironmentType()
returns the first match:
| Env | Detected by |
|---|
ddev | .ddev/config.yaml |
hooli | parent dir has both hooli/ and docker-compose.yml |
warden | .warden/warden-env.yml or .env.warden |
docker-compose | docker-compose.yml / docker-compose.yaml / compose.yml |
docker | /.dockerenv exists or DOCKER_CONTAINER is set |
native | fallback |
McpConfigWriter::writeMcpConfig($envType) turns that into the command/args written to
.mcp.json (native → php vendor/bin/bricklayer-mcp; ddev → ddev exec …; warden →
warden shell -c …; docker-compose → docker compose exec -T -u <user> …). The container user
is resolved in McpConfigWriter, precedence: BRICKLAYER_CONTAINER_USER → owner of
composer.json (fileowner + posix_getpwuid) → Hooli .env APACHE_USER. For dynamic
container names, bin/bricklayer-mcp-docker finds the PHP container at runtime from docker ps
names — excluding phpmyadmin|mysql|mariadb|redis|elastic|opensearch|varnish|mailhog|rabbitmq|database|nginx-proxy,
then matching apache-php|php-fpm|php|magento|web|app. Changing detection or the emitted command
means touching these three together (MagentoDetector, McpConfigWriter, the wrapper).
Config and security model (implementation view)
Configuration resolves env var > .bricklayer.json > deploy-mode default.
Security layers, innermost-relevant for contributors:
- Fail closed.
ChecksConfig::isProductionMode() returns true if the deploy mode can't be
read (the catch returns true). New destructive paths inherit this only if they call the
guards above — so always use the guard helpers, never re-derive the mode yourself.
code-runner is hard-blocked in production (no config override), runs through PsySH when
\Psy\Shell exists (degraded eval fallback otherwise, with the active engine reported in
runtime), defaults to read-only (allow_write:false, DB transaction rolled back), and
rejects the dangerous-code blocklist (DANGEROUS_PATTERNS in CodeRunnerTools).
Its six bare helpers (get/create/repo/config/query/runLog) must work in both
runtimes and stay bound to the live ObjectManager across calls — a regression here is what
v1.15.1 fixed, so any change touching helper registration needs a test under both engines.
The design rule every change is judged against
Can an agent already do this without Bricklayer — via code-runner or plain file-reading?
If yes, the feature needs strong justification.
Bricklayer's irreplaceable value is runtime introspection of a live install: resolved DI
across all modules, real plugin execution order, deployed EAV, actual DB state, error
diagnostics with DI/plugin context. That is what file-reading and an LLM's priors cannot give.
CRUD wrappers (product-create, order-cancel, …) are conveniences around what code-runner
already does and carry their weight only as ergonomic shortcuts. When reviewing or proposing a
new tool, apply the runtime-vs-static test first. Bricklayer stays narrow on runtime behaviour
and leaves static analysis (LSP/Intelephense), code-location (Magector), and library docs
(Context7) to the complementary tools around it — do not absorb their jobs.
QA gates
Defined as composer scripts (composer.json):
composer test
composer phpstan
composer phpcs
composer phpcbf
composer check
phpstan.neon.dist is level 8 over src + tests, ignoring "Magento class not found" errors
(Magento isn't on the analysis path). phpunit.xml.dist runs the Unit suite from tests/Unit,
which mirrors the src/ tree (tests/Unit/Tool/, tests/Unit/Config/, tests/Unit/Command/,
…) with *Test.php files.
Bootstrap gotcha. phpunit.xml.dist sets bootstrap="../../../vendor/autoload.php" —
i.e. it expects the package to live inside a Magento install's vendor/inchoo/magento-bricklayer/.
Running the suite from a standalone clone may require pointing the bootstrap at the package's
own vendor/autoload.php. Check this before assuming a test failure is real.
Tests lean on the testability seams in the code: tool/compiler/context constructors accept
optional $magentoRoot/$packageRoot, McpServerFactory exposes a protected getToolCount()
for doubles, and discovery is reflection-based so tests can assert against real attributes.
Conventions and gotchas
declare(strict_types=1) + the Inchoo copyright header on every PHP file; PSR-12; PSR-4
autoload Inchoo\MagentoBricklayer\ => src/.
- Counts are dynamic by design. Don't introduce a hardcoded tool/category/prompt count
anywhere — wire it to
ToolRegistry::count() / a reflection scan instead. The server
instructions in McpServerFactory::getServerInstructions() inject {$toolCount} and must
stay that way.
ERROR_PATTERNS is still inline in DiagnosticTools.php (a planned move to JSON did
not ship). The #[ToolGroup] attribute does not exist and ToolScanner is
still present — if a task description references either, it's describing a refactor that
was never applied.
- Sentinel-file staleness:
MagentoBootstrap::SENTINEL_FILES is exactly two —
app/etc/config.php (module list; changes on setup:upgrade, module:enable/disable) and
generated/metadata/global.php (compiled DI; changes on setup:di:compile).
RequiresMagento::requireMagento() calls reinitializeIfStale() on every tool call (one
filemtime() per file) and rebuilds the ObjectManager if either moved. The manual
reinitialize tool (DevelopmentTools) covers edits that touch neither sentinel (e.g.
config.xml without recompiling) and is also where defined code-runner functions are
cleared, via a direct CodeRunnerTools::clearDefinedFunctions() call — there is no callback
registry.
tests/Unit/Command/InstallCommandTest.php mentions ToolException on purpose — it's a
regression guard asserting the class no longer exists (assertFalse(class_exists(...))), not
stale code. Leave it.
- The MCP SDK is pinned
mcp/sdk: ^0.3 || ^1.0 deliberately to avoid breaking protocol changes;
magento/framework is intentionally not a dependency (runtime detection avoids version
conflicts).
Dependencies
php >=8.1, mcp/sdk ^0.3 || ^1.0, psy/psysh ^0.12, symfony/console ^5.4 || ^6.0 || ^7.0,
psr/log ^2.0 || ^3.0; dev: phpunit ^10 || ^11, phpstan ^1.10, squizlabs/php_codesniffer ^3.7.
The mcp/sdk pin and the deliberate absence of magento/framework are explained under
"Conventions and gotchas". For the current shape of the surface (how many tools, prompts,
categories, …) reflect or run the commands in "Rule zero" — it is intentionally not written
down here.