Added
-
content_validateaction onstatamic-content-facade— Validates content that is already stored against its blueprints. Writes through this addon are validated on the way in; content that arrives another way (git merges, hand-edited YAML, blueprints changed after the content was written) was previously invisible. Each record gets two passes: the blueprint's own validation rules, evaluated the same way a Control Panel save evaluates them, plus structural checks the rule engine cannot express — replicator/bard blocks naming a set that no longer exists, sets and grid rows storing keys the blueprint dropped,select/radio/button_group/checkboxesvalues outside the declared options, assets fields pointing at missing files, and navigation items linking to deleted entries. All of these pass rule validation silently while breaking at render time. Supportsscope,collection/taxonomyfilters,severityfiltering, offset/limit paging across the combined record stream, and amax_findingscap that keeps summary counts accurate when the list is truncated -
ValidatesContentRecordsconcern — The two-pass record validation above, extracted so other routers can reuse it. The rule pass is isolated per record: a malformed stored value that makes a fieldtype's rule builder throw is reported as arule_engine_errorwarning rather than aborting the sweep, and the structural pass still runs to name the underlying shape problem -
MCP resources for blueprints — The server now exposes the
resourcescapability, which it previously left unused.statamic://blueprintslists every readable blueprint with its URI;statamic://blueprints/{namespace}/{handle}returns one blueprint's fields, so a client can look up field structure to shape a write without spending a tool call. BecauseRequireMcpPermissiondefers scope checks to the primitive, resources run the same four gates a router read does — tool enablement, token scope (blueprints:read), resource policy, and Statamic permissions — via a newAuthorizesResourceAccessconcern. Blueprints the resource policy hides are absent from the index, not merely refused on read -
#[Title]on every tool —tools/listhas always carried atitlefield; without the attribute it fell back toStr::headline(class_basename()), so clients displayed "Entries Router". Tools now declare their own display titles -
Typed validation model —
Finding,RecordRef, and theSeverity/FindingType/RecordTypeenums replace thearray<string, mixed>bags the validation sweep threaded through its call chain. Severity is derived from the finding type rather than passed alongside it, so a finding cannot be built with a severity that contradicts what it describes; findings become arrays only at the MCP response boundary -
Testing/InteractsWithMcpandTesting/FakeTransport— Shipped testing helpers for driving this addon's MCP server, dogfooded by the package's own suite. Atests/Fixturescomposition site is included in the PHPStan paths so the traits are analysed where they are actually mixed in — which immediately caught a wrongclass-stringbound in the trait itself -
Supply-chain gate —
bin/check-licenses.phpfails the build on any non-permissive dependency (SPDX dual-licensing handled: a package passes if any arm is permissive), andbin/generate-sbom.phpemits a deterministic CycloneDX 1.5sbom.jsonwith sorted components and a content-derived serial number, so it only changes when dependencies do. Wired into CI along withcomposer audit --no-dev, plus a newcomposer qaaggregate. CI validates the generated document rather than diffing it against the committed one:composer.lockis gitignored because this is a library, so a freshly resolved lock legitimately differs and a drift check would fail whenever any transitive dependency publishes. The license check found one real case:statamic/cmsis proprietary, recorded as a justified exception because a Statamic addon cannot avoid depending on Statamic -
SECURITY.md— Private vulnerability reporting via GitHub, an explicit split between what this addon secures and what the operator does, and a limitations section stating plainly that the audit log is append-only by convention with no hash chain (neither tamper-proof nor tamper-evident), that confirmation tokens are replayable within their window, and thatrequire_httpsfalls back to off when the published config predates the key
Fixed
- The MIT license text actually ships —
composer.jsonhas always declared MIT, but the repository never contained aLICENSEfile, so the grant existed only as metadata. The standard MIT text is now included - Entry updates no longer fail on blueprints with a required slug (#39) — The #27 fix removed the slug from the validated payload entirely, so Statamic's default slug field (
validate: [required, UniqueEntryValue…]) could never be satisfied: every update failed with "The Slug field is required", whether the caller omitted the slug or resent the current one. The entry's effective slug is now injected back into the validation payload, and bothFieldsValidatorinvocations (including theTypeErrorfallback) resolve theUniqueEntryValue({collection}, {id}, {site})placeholders viawithReplacements(), so the rule excludes the entry being updated — the false positive #27 was about — while a slug owned by another entry is still rejected dateno longer has to be resent on every update of a dated collection — Like the slug, the date is an entry property absent from the merged data payload, so a blueprint with a required date field failed any update that did not repeat a date the caller never meant to change. The entry's current date now satisfies the rule when the payload omits it- Explicit slug on create actually works —
createEntry()read$arguments['slug'], but the tool schema never declared the parameter, so no client could send it and the slug was always derived from the title. The schema now declaresslug, and create also accepts it asdata.slug— the shape update uses — storing it as an entry property in both cases, never as a data key - PHPStan level 9 is no longer partly disabled — The config carried blanket
ignoreErrorspatterns (#Method .* should return .* but returns mixed#,#Cannot call method .* on .*\|null#,#Parameter .* expects .*, mixed given#, and four more) that suppressed whole error classes acrosssrc/, so "level 9 clean" meant considerably less than it sounded. Removing them surfaced 54 real errors — almost all method calls onBlueprint|null,Entry|null, orGlobalSet|nullafter a lookup, becauserequireResource()returned an error array without narrowing the variable. Every site now checks for null explicitly (identical messages, identical behaviour, and the analyser can see it), the untyped Statamic/Eloquent return values are narrowed rather than cast, and the four@phpstan-ignoreannotations onabort()calls are gone.requireResource()itself is removed, having no callers left - CI verifies formatting instead of rewriting it — The Tests workflow ran Pint in fix mode, committed the result, and pushed it back to the branch; it now runs
pint --testand fails on violations, matching what the release workflow already does - CI exercises both Laravel majors —
composer.jsonclaims Laravel 12 and 13 viaorchestra/testbench: ^10.0 || ^11.0, but the matrix only varied PHP, so every job resolved testbench 11 and the Laravel 12 claim was never verified. The matrix now spans both. The suite passes on Laravel 12 - Package classes are no longer
final— Nine classes were sealed, blocking consumers from extending or decorating what the package ships - Failed tool calls set the MCP
isErrorflag — Every response was returned viaResponse::structured(), which never marks an error, so a failed call arrived at the client indistinguishable from a successful one; only a model parsing the JSON body would notice"success": false. Failures are now assembled fromResponse::error()plus the same structured content, so both the protocol flag and the full envelope (including confirmation tokens) survive - Plaintext credentials no longer reach stack traces —
TokenService::validateToken()/findByPlainText(),ConfirmationTokenManager's token methods,AuthenticateForMcp::authenticateWithCredentials(), and everyClientConfigGeneratormethod took secrets as plain parameters. In the stdio server,setupErrorHandling()writesgetTraceAsString()to stderr, so a throw anywhere in those call chains logged the bearer token verbatim. All are now marked#[\SensitiveParameter], matching the hardening laravel/mcp applied upstream in v0.9.0 - Server version no longer hardcoded —
StatamicMcpServer::$versionwas the literal'2.8.0'and would have drifted at the next release. It is read from Composer's installed-package metadata, falling back to0.0.0only when that is unavailable - Release workflow no longer hangs — The release job ran
pest --parallel, which is not parallel-safe:Statamic\Testing\AddonTestCasepoints every Stache store, andPreventsSavingStacheItemsToDisk'sdev-nulldirectory, at one sharedtests/__fixtures__path, so ParaTest workers deleted each other's fixtures. This produced ~34 spurious failures or, when workers collided on the file-storeflock()calls, a hang that burned the 6h job timeout (v2.6.1 and v2.8.0 both died this way, and both releases had to be published by hand). The release job now runs the same single-processpestthat gates pull requests, and every job has an explicittimeout-minutesso a hang fails in minutes instead of hours - Release notes are no longer empty — The changelog extraction matched
[v2.8.0]against headings written as[2.8.0], so it never selected anything. The tag'svprefix is now stripped, with a fallback message if the section is missing - Release workflow verifies formatting instead of rewriting it — The
Fix code formattingstep ran Pint in fix mode and discarded the result; it now runspint --testand fails on violations
Changed
- Docs follow the standard topic layout —
introduction.mdbecameindex.md,quickstart.mdmoved to the docs root, and a generatedrequirements.mdstates only what the resolver enforces.docs/superpowers/and its subfolders gained the_index.mdlandings and frontmatter they were missing, which had been downgrading the docs site's grading fromcompletetopartial. All relative links repaired and verified - Pest constraint widened to
^4.1 || ^5.0— Pest 5 is stable; the package was a major behind - Tests now exercise the real MCP protocol — Every test drove tools through
execute()directly, so JSON-RPC argument delivery, response serialization, theisErrorflag, and outputSchema conformance had no coverage at all;StatamicMcpServerTesteven read protected properties by reflection. A newMcpProtocolSurfaceTestdrives tools throughStatamicMcpServer::tool(). This required registeringLaravel\Mcp\Server\McpServiceProviderinTestCase::getPackageProviders()— Testbench does not run package auto-discovery, so without itLaravel\Mcp\Requestnever receives arguments and protocol-level tests pass while asserting nothing composer stanpasses--memory-limit=2G— PHPStan crashed its parallel worker at PHP's default 128M; 1G proved marginal oncetests/Fixturesjoined the analysis paths- Removed the
composer test:parallelscript — It could not work for the reason above; usecomposer test