From 801d15ef944ca530f95c56ca3bed8aeb999eb6f7 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Fri, 1 May 2026 19:03:16 +0200 Subject: [PATCH] feat(blueprints): emit per-field wire-format spec to guide MCP agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes ENG-804. Adds a FieldFormatSpec service that derives a wire-format guidance object from each Statamic Field — bard inline vs full, replicator/grid/group item shape, allowed set types, recursive set definitions, markdown vs ProseMirror distinction, relationship/asset/date input shapes — and returns it via BlueprintsRouter::get on the new _format_spec key for every field. Agents reading a blueprint now see the exact payload contract instead of having to interpret raw fieldtype config. Backs the format spec with a dedicated FieldFormatException class. The SanitizesFieldData trait throws this specific subclass for malformed bard/replicator/grid/table input, and BaseStatamicTool::execute() allow-lists it (alongside Laravel's ValidationException and Statamic's FieldtypeNotFoundException / BlueprintNotFoundException) so the field-path error message reaches clients in production rather than being replaced with the generic "An error occurred" placeholder. Other Throwables continue to flow through the production sanitizer to avoid leaking internals. Schema descriptions on BlueprintsRouter were also corrected to reflect the actual list-vs-get scoping of include_fields, include_config, include_format_spec, and max_format_depth. --- src/Mcp/Exceptions/FieldFormatException.php | 17 + src/Mcp/Support/FieldFormatSpec.php | 525 ++++++++++++++++++ src/Mcp/Tools/BaseStatamicTool.php | 23 +- src/Mcp/Tools/Concerns/SanitizesFieldData.php | 12 +- src/Mcp/Tools/Routers/BlueprintsRouter.php | 24 +- src/Mcp/Tools/Routers/EntriesRouter.php | 12 + tests/Integration/BlueprintFormatSpecTest.php | 171 ++++++ tests/Integration/ClientSafeExceptionTest.php | 124 +++++ tests/Unit/FieldFormatSpecTest.php | 201 +++++++ 9 files changed, 1098 insertions(+), 11 deletions(-) create mode 100644 src/Mcp/Exceptions/FieldFormatException.php create mode 100644 src/Mcp/Support/FieldFormatSpec.php create mode 100644 tests/Integration/BlueprintFormatSpecTest.php create mode 100644 tests/Integration/ClientSafeExceptionTest.php create mode 100644 tests/Unit/FieldFormatSpecTest.php diff --git a/src/Mcp/Exceptions/FieldFormatException.php b/src/Mcp/Exceptions/FieldFormatException.php new file mode 100644 index 0000000..358d579 --- /dev/null +++ b/src/Mcp/Exceptions/FieldFormatException.php @@ -0,0 +1,17 @@ +|null + */ + public function for(Field $field, int $depth = 0): ?array + { + if ($depth > $this->maxDepth) { + return [ + 'wire_format' => 'truncated', + 'shape' => 'truncated', + 'rules' => ["Recursion truncated at depth {$this->maxDepth}. Re-fetch this field's blueprint with a higher max_format_depth to see nested definitions."], + ]; + } + + return match ($field->type()) { + 'bard' => $this->bardSpec($field, $depth), + 'replicator' => $this->replicatorSpec($field, $depth), + 'grid' => $this->gridSpec($field, $depth), + 'group' => $this->groupSpec($field, $depth), + 'markdown' => $this->markdownSpec(), + 'text', 'textarea', 'slug', 'code', 'yaml', 'html', 'video', 'color', 'icon' => $this->stringSpec(), + 'integer' => ['wire_format' => 'integer', 'shape' => 'integer'], + 'float' => ['wire_format' => 'number', 'shape' => 'number'], + 'toggle' => ['wire_format' => 'boolean', 'shape' => 'boolean'], + 'select', 'radio', 'button_group' => $this->selectSpec($field), + 'checkboxes' => $this->checkboxesSpec($field), + 'entries', 'terms', 'users', 'taxonomy' => $this->relationshipSpec($field), + 'assets' => $this->assetsSpec($field), + 'date' => [ + 'wire_format' => 'string_or_object', + 'shape' => 'date', + 'rules' => [ + 'Accepts any of: ISO 8601 datetime ("2024-01-15T12:00:00.000Z"), date-only ("2024-01-15"), date-time ("2024-01-15 12:00"), or split object ({"date":"2024-01-15","time":"12:00"}).', + 'The MCP server normalizes all of these to the Statamic-required ISO 8601 Zulu format with milliseconds before validation.', + ], + 'examples' => [ + '2024-01-15T12:00:00.000Z', + '2024-01-15', + '2024-01-15 12:00', + ['date' => '2024-01-15', 'time' => '12:00'], + ], + ], + 'time' => [ + 'wire_format' => 'string', + 'shape' => 'time', + 'rules' => ['Time string in HH:MM or HH:MM:SS (24-hour).'], + ], + 'table' => $this->tableSpec(), + 'link' => [ + 'wire_format' => 'string', + 'shape' => 'url_or_entry_reference', + 'rules' => ['URL string OR statamic://entry/ reference.'], + ], + default => null, + }; + } + + /** + * Build specs for a collection of fields. + * + * @param Collection $fields + * + * @return list> + */ + public function fieldsSpec(Collection $fields, int $depth = 0): array + { + $out = []; + foreach ($fields as $handle => $field) { + $entry = [ + 'handle' => (string) $handle, + 'type' => $field->type(), + ]; + + $spec = $this->for($field, $depth); + if ($spec !== null) { + $entry['_format_spec'] = $spec; + } + + $out[] = $entry; + } + + return $out; + } + + /** + * @return array + */ + private function bardSpec(Field $field, int $depth): array + { + $fieldtype = $field->fieldtype(); + + $config = $field->config(); + $isInline = (bool) ($config['inline'] ?? false); + /** @var array $rawButtons */ + $rawButtons = is_array($config['buttons'] ?? null) ? $config['buttons'] : []; + $buttons = array_values(array_filter($rawButtons, 'is_string')); + + $marks = $this->bardMarksFromButtons($buttons); + $allowedHeadings = $this->bardHeadingsFromButtons($buttons); + + if ($isInline) { + return [ + 'wire_format' => 'array', + 'shape' => 'bard_inline', + 'rules' => [ + 'Inline Bard: array of inline ProseMirror nodes only.', + 'Do NOT wrap content in {type:"paragraph",...} nodes — paragraph is forbidden in inline mode.', + 'Allowed nodes: text, hardBreak.', + ], + 'allowed_node_types' => ['text', 'hardBreak'], + 'allowed_marks' => $marks, + 'common_mistakes' => [ + 'Wrapping nodes in a paragraph — inline bard rejects block-level wrappers.', + 'Sending a plain string instead of an array of inline nodes.', + ], + 'example' => [ + ['type' => 'text', 'text' => 'Hello '], + ['type' => 'text', 'marks' => [['type' => 'bold']], 'text' => 'world'], + ], + ]; + } + + $allowedSetTypes = []; + $setDefinitions = []; + + if ($fieldtype instanceof Bard) { + /** @var Collection> $sets */ + $sets = $fieldtype->flattenedSetsConfig(); + $allowedSetTypes = array_keys($sets->all()); + + if ($depth < $this->maxDepth) { + foreach ($allowedSetTypes as $setHandle) { + /** @var Collection $setFields */ + $setFields = $fieldtype->fields($setHandle)->all(); + $setDefinitions[$setHandle] = [ + 'fields' => $this->fieldsSpec($setFields, $depth + 1), + ]; + } + } + } + + $spec = [ + 'wire_format' => 'array', + 'shape' => 'bard_block', + 'rules' => [ + 'Full Bard: array of ProseMirror block nodes.', + 'Custom blocks (sets) appear as { type: "set", attrs: { values: { type: , ...fields } } }.', + 'Set handle goes inside attrs.values.type, NOT on the outer node.', + 'Inside set fields, the same wire-format rules apply per fieldtype (markdown is a string, nested bard is nodes, etc.).', + ], + 'allowed_node_types' => ['paragraph', 'heading', 'bulletList', 'orderedList', 'listItem', 'set', 'hardBreak'], + 'allowed_marks' => $marks, + 'allowed_heading_levels' => $allowedHeadings, + 'allowed_set_types' => $allowedSetTypes, + 'set_node_shape' => [ + 'type' => 'set', + 'attrs' => [ + 'values' => [ + 'type' => '', + '...' => 'fields per set_definitions[type]', + ], + ], + ], + 'common_mistakes' => [ + 'Sending a markdown string instead of a ProseMirror tree.', + 'Putting the set handle on the outer node — it must go in attrs.values.type.', + 'Sending ProseMirror to a markdown sub-field (e.g. callout.content). Markdown sub-fields take plain markdown strings.', + 'Missing the attrs.values wrapper around set field data.', + ], + 'example' => [ + ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => 'Body paragraph']]], + [ + 'type' => 'set', + 'attrs' => [ + 'values' => [ + 'type' => '', + ], + ], + ], + ], + ]; + + if ($setDefinitions !== []) { + $spec['set_definitions'] = $setDefinitions; + } + + return $spec; + } + + /** + * @return array + */ + private function replicatorSpec(Field $field, int $depth): array + { + $fieldtype = $field->fieldtype(); + + $allowedSetTypes = []; + $setDefinitions = []; + + if ($fieldtype instanceof Replicator) { + /** @var Collection> $sets */ + $sets = $fieldtype->flattenedSetsConfig(); + $allowedSetTypes = array_keys($sets->all()); + + if ($depth < $this->maxDepth) { + foreach ($allowedSetTypes as $setHandle) { + /** @var Collection $setFields */ + $setFields = $fieldtype->fields($setHandle)->all(); + $setDefinitions[$setHandle] = [ + 'fields' => $this->fieldsSpec($setFields, $depth + 1), + ]; + } + } + } + + $spec = [ + 'wire_format' => 'array', + 'shape' => 'replicator_items', + 'rules' => [ + 'Replicator: array of items.', + 'Each item: { id, type, enabled, ...fields per set_definitions[type] }.', + 'id: short alphanumeric string, unique within the array (e.g. "a1B2c3D4").', + 'type: must be one of allowed_set_types.', + 'enabled: boolean — true unless deliberately disabling the item.', + ], + 'item_required_keys' => ['id', 'type', 'enabled'], + 'allowed_set_types' => $allowedSetTypes, + 'item_shape' => [ + 'id' => '', + 'type' => '', + 'enabled' => true, + '...' => 'fields per set_definitions[type]', + ], + 'common_mistakes' => [ + 'Forgetting id, type, or enabled on items.', + 'Using a set handle not in allowed_set_types.', + 'Reusing the same id across items in the array.', + ], + 'example' => [ + ['id' => 'a1B2c3D4', 'type' => '', 'enabled' => true], + ], + ]; + + if ($setDefinitions !== []) { + $spec['set_definitions'] = $setDefinitions; + } + + return $spec; + } + + /** + * @return array + */ + private function gridSpec(Field $field, int $depth): array + { + $fieldtype = $field->fieldtype(); + $rowFields = []; + + if ($fieldtype instanceof Grid && $depth < $this->maxDepth) { + /** @var Collection $fields */ + $fields = $fieldtype->fields()->all(); + $rowFields = $this->fieldsSpec($fields, $depth + 1); + } + + return [ + 'wire_format' => 'array', + 'shape' => 'grid_rows', + 'rules' => [ + 'Grid: array of row objects.', + 'Each row is an object containing the row fields directly (NO id/type/enabled wrapper).', + ], + 'row_fields' => $rowFields, + 'common_mistakes' => [ + 'Adding a "type" key on rows — that is for replicator, not grid.', + 'Wrapping rows in { values: ... } — grid rows are flat objects.', + ], + ]; + } + + /** + * @return array + */ + private function groupSpec(Field $field, int $depth): array + { + $fieldtype = $field->fieldtype(); + $groupFields = []; + + if ($fieldtype instanceof Group && $depth < $this->maxDepth) { + /** @var Collection $fields */ + $fields = $fieldtype->fields()->all(); + $groupFields = $this->fieldsSpec($fields, $depth + 1); + } + + return [ + 'wire_format' => 'object', + 'shape' => 'group', + 'rules' => [ + 'Group: a single object containing the group fields directly.', + ], + 'group_fields' => $groupFields, + ]; + } + + /** + * @return array + */ + private function markdownSpec(): array + { + return [ + 'wire_format' => 'string', + 'shape' => 'markdown', + 'rules' => [ + 'Plain markdown string. Supports **bold**, *italic*, [links](url), lists, code, etc.', + 'Do NOT send a ProseMirror node tree — markdown fields take a string only.', + ], + 'common_mistakes' => [ + 'Sending an array of ProseMirror nodes (that is for bard fields, not markdown).', + ], + 'example' => '**Heads up** — see [pricing](https://example.com).', + ]; + } + + /** + * @return array + */ + private function stringSpec(): array + { + return ['wire_format' => 'string', 'shape' => 'string']; + } + + /** + * @return array + */ + private function selectSpec(Field $field): array + { + $config = $field->config(); + $multiple = (bool) ($config['multiple'] ?? false); + $rawOptions = $config['options'] ?? []; + $options = []; + if (is_array($rawOptions)) { + // Statamic options can be either a flat list or a key=>label map. + $isAssoc = array_keys($rawOptions) !== range(0, count($rawOptions) - 1); + $options = $isAssoc ? array_keys($rawOptions) : array_values(array_filter($rawOptions, 'is_scalar')); + } + + return [ + 'wire_format' => $multiple ? 'array' : 'string', + 'shape' => $multiple ? 'enum_array' : 'enum', + 'allowed_values' => array_map(static fn ($v): string => (string) $v, $options), + ]; + } + + /** + * @return array + */ + private function checkboxesSpec(Field $field): array + { + $config = $field->config(); + $rawOptions = $config['options'] ?? []; + $options = []; + if (is_array($rawOptions)) { + $isAssoc = array_keys($rawOptions) !== range(0, count($rawOptions) - 1); + $options = $isAssoc ? array_keys($rawOptions) : array_values(array_filter($rawOptions, 'is_scalar')); + } + + return [ + 'wire_format' => 'array', + 'shape' => 'enum_array', + 'allowed_values' => array_map(static fn ($v): string => (string) $v, $options), + ]; + } + + /** + * @return array + */ + private function relationshipSpec(Field $field): array + { + $config = $field->config(); + $maxItems = $config['max_items'] ?? null; + $singular = $maxItems === 1; + + return [ + 'wire_format' => 'array', + 'shape' => 'relationship_ids', + 'rules' => [ + $singular + ? 'Single-relationship field. Wire format is still an array — wrap the single UUID in [].' + : 'Array of UUIDs referencing the related resources.', + ], + 'item_format' => 'uuid_string', + 'common_mistakes' => [ + 'Sending a bare string — relationship fields always expect arrays.', + 'Including a prefix like "entry::" — pass the UUID directly.', + ], + ]; + } + + /** + * @return array + */ + private function assetsSpec(Field $field): array + { + $config = $field->config(); + $maxFiles = $config['max_files'] ?? null; + $singular = $maxFiles === 1; + + return [ + 'wire_format' => $singular ? 'string' : 'array', + 'shape' => $singular ? 'asset_path' : 'asset_paths', + 'rules' => [ + $singular + ? 'Single asset path string, e.g. "/assets/foo.png".' + : 'Array of asset path strings.', + ], + ]; + } + + /** + * @return array + */ + private function tableSpec(): array + { + return [ + 'wire_format' => 'array', + 'shape' => 'table_rows', + 'rules' => [ + 'Table: array of row objects.', + 'Each row: { cells: [, , ...] }.', + 'Cell values must be scalar (string|number|null) — NOT objects.', + ], + 'common_mistakes' => [ + 'Wrapping cells as { value: x } — pass the scalar directly.', + 'Sending an array of arrays without the cells wrapper.', + ], + 'example' => [ + ['cells' => ['Header A', 'Header B']], + ['cells' => ['Row 1 A', 'Row 1 B']], + ], + ]; + } + + /** + * Map Bard buttons config to ProseMirror mark types. + * + * @param list $buttons + * + * @return list + */ + private function bardMarksFromButtons(array $buttons): array + { + $map = [ + 'bold' => 'bold', + 'italic' => 'italic', + 'underline' => 'underline', + 'strikethrough' => 'strike', + 'code' => 'code', + 'subscript' => 'subscript', + 'superscript' => 'superscript', + 'small' => 'small', + 'anchor' => 'link', + ]; + + $marks = []; + foreach ($buttons as $btn) { + if (isset($map[$btn])) { + $marks[] = $map[$btn]; + } + } + + return array_values(array_unique($marks)); + } + + /** + * Map Bard buttons config to allowed heading levels. + * + * @param list $buttons + * + * @return list + */ + private function bardHeadingsFromButtons(array $buttons): array + { + $levels = []; + foreach ($buttons as $btn) { + if (preg_match('/^h([1-6])$/', $btn, $m) === 1) { + $levels[] = (int) $m[1]; + } + } + sort($levels); + + return array_values(array_unique($levels)); + } +} diff --git a/src/Mcp/Tools/BaseStatamicTool.php b/src/Mcp/Tools/BaseStatamicTool.php index c575d2e..7de2416 100644 --- a/src/Mcp/Tools/BaseStatamicTool.php +++ b/src/Mcp/Tools/BaseStatamicTool.php @@ -7,15 +7,19 @@ use Cboxdk\StatamicMcp\Mcp\DataTransferObjects\ErrorResponse; use Cboxdk\StatamicMcp\Mcp\DataTransferObjects\ResponseMeta; use Cboxdk\StatamicMcp\Mcp\DataTransferObjects\SuccessResponse; +use Cboxdk\StatamicMcp\Mcp\Exceptions\FieldFormatException; use Cboxdk\StatamicMcp\Mcp\Support\ToolLogger; use Illuminate\Contracts\JsonSchema\JsonSchema as JsonSchemaContract; use Illuminate\JsonSchema\JsonSchema; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; use Laravel\Mcp\Server\Tool; +use Statamic\Exceptions\BlueprintNotFoundException; +use Statamic\Exceptions\FieldtypeNotFoundException; use Statamic\Statamic; abstract class BaseStatamicTool extends Tool @@ -179,7 +183,7 @@ final public function execute(array $arguments): array ]); $rawMessage = $this->sanitizeErrorMessage($e->getMessage()); - $errorMessage = app()->environment('local', 'testing') + $errorMessage = ($this->isClientSafeException($e) || app()->environment('local', 'testing')) ? "{$prefix} in {$toolName}: {$rawMessage}" : "{$prefix} in {$toolName}: An error occurred. Check server logs for details."; @@ -423,6 +427,23 @@ private function containsNullByte(mixed $value): bool return false; } + /** + * Determine if an exception's message is curated and safe to expose to clients + * even outside local/testing environments. + * + * The default production handler genericises messages to avoid leaking internals. + * For these specific exception classes the message content is authored by us + * (or by trusted upstream code) with field paths / validation rules, so the + * message is more valuable than the generic placeholder. + */ + protected function isClientSafeException(\Throwable $e): bool + { + return $e instanceof FieldFormatException + || $e instanceof ValidationException + || $e instanceof FieldtypeNotFoundException + || $e instanceof BlueprintNotFoundException; + } + /** * Sanitize error messages (minimal sanitization for Claude compatibility). */ diff --git a/src/Mcp/Tools/Concerns/SanitizesFieldData.php b/src/Mcp/Tools/Concerns/SanitizesFieldData.php index e4c9a59..778cf75 100644 --- a/src/Mcp/Tools/Concerns/SanitizesFieldData.php +++ b/src/Mcp/Tools/Concerns/SanitizesFieldData.php @@ -4,8 +4,8 @@ namespace Cboxdk\StatamicMcp\Mcp\Tools\Concerns; +use Cboxdk\StatamicMcp\Mcp\Exceptions\FieldFormatException; use Illuminate\Support\Collection; -use InvalidArgumentException; use Statamic\Fields\Blueprint; use Statamic\Fields\Field; use Statamic\Fieldtypes\Bard; @@ -171,7 +171,7 @@ private function sanitizeBardValue(Field $field, mixed $value, bool $allowLegacy continue; } - throw new InvalidArgumentException("Field [{$path}.{$index}] references unknown Bard set [" . (is_scalar($setType) ? (string) $setType : 'invalid') . ']'); + throw new FieldFormatException("Field [{$path}.{$index}] references unknown Bard set [" . (is_scalar($setType) ? (string) $setType : 'invalid') . ']'); } /** @var array $bardSetValues */ @@ -276,7 +276,7 @@ private function sanitizeReplicatorValue(Field $field, mixed $value, bool $allow continue; } - throw new InvalidArgumentException("Field [{$path}.{$index}] references unknown replicator set [" . (is_scalar($setType) ? (string) $setType : 'invalid') . ']'); + throw new FieldFormatException("Field [{$path}.{$index}] references unknown replicator set [" . (is_scalar($setType) ? (string) $setType : 'invalid') . ']'); } $sanitized[] = $this->sanitizeFieldCollection( @@ -370,7 +370,7 @@ private function normalizeTableCell(mixed $cell, bool $allowLegacyCoercion, stri return null; } - throw new InvalidArgumentException( + throw new FieldFormatException( "Field [{$path}] table cell must be a string or null, received " . get_debug_type($cell) . '.' ); } @@ -411,10 +411,10 @@ private function sanitizeRelationshipValue(mixed $value): array return []; } - private function invalidStructuredValue(string $path, string $fieldType, mixed $value): InvalidArgumentException + private function invalidStructuredValue(string $path, string $fieldType, mixed $value): FieldFormatException { $received = get_debug_type($value); - return new InvalidArgumentException("Field [{$path}] expects {$fieldType} data as an array, received {$received}."); + return new FieldFormatException("Field [{$path}] expects {$fieldType} data as an array, received {$received}."); } } diff --git a/src/Mcp/Tools/Routers/BlueprintsRouter.php b/src/Mcp/Tools/Routers/BlueprintsRouter.php index e899a9a..58a027a 100644 --- a/src/Mcp/Tools/Routers/BlueprintsRouter.php +++ b/src/Mcp/Tools/Routers/BlueprintsRouter.php @@ -4,6 +4,7 @@ namespace Cboxdk\StatamicMcp\Mcp\Tools\Routers; +use Cboxdk\StatamicMcp\Mcp\Support\FieldFormatSpec; use Cboxdk\StatamicMcp\Mcp\Tools\BaseRouter; use Cboxdk\StatamicMcp\Mcp\Tools\Concerns\ClearsCaches; use Illuminate\Contracts\JsonSchema\JsonSchema as JsonSchemaContract; @@ -19,7 +20,7 @@ use Statamic\Fields\FieldtypeRepository; #[Name('statamic-blueprints')] -#[Description('Manage Statamic blueprints — the schema definitions for all content types. Call get before creating/updating entries, terms, or globals to understand required fields. Actions: list, get, create, update, delete, scan, generate, types, validate.')] +#[Description('Manage Statamic blueprints — the schema definitions for all content types. Call get before creating/updating entries, terms, or globals to understand required fields AND the _format_spec for each field (wire format, allowed types, common mistakes). Actions: list, get, create, update, delete, scan, generate, types, validate.')] class BlueprintsRouter extends BaseRouter { use ClearsCaches; @@ -98,9 +99,13 @@ protected function defineSchema(JsonSchemaContract $schema): array 'include_details' => JsonSchema::boolean() ->description('Include field type and configuration details in response. Default: false for list, true for get'), 'include_fields' => JsonSchema::boolean() - ->description('Include field definitions in list response. Default: false (reduces response size)'), + ->description('Only applies to list. Include field handle/type/display in list response. Default: true.'), 'include_config' => JsonSchema::boolean() - ->description('Include full field config in responses (default true for get, false for list)'), + ->description('Only applies to get. Include full field config in the response. Default: true.'), + 'include_format_spec' => JsonSchema::boolean() + ->description('Only applies to get. Include _format_spec on each field describing the exact wire format (shape, allowed types, set definitions, common mistakes, example). Highly recommended before writing entries with bard, replicator, grid, or group fields. Default: true.'), + 'max_format_depth' => JsonSchema::integer() + ->description('Only applies to get. Recursion depth for _format_spec into nested set fields. 0 = top-level only. Default: 2.'), 'output_format' => JsonSchema::string() ->description('Type generation output format. Choose based on your project\'s language') ->enum(['typescript', 'php', 'json-schema', 'all']), @@ -207,6 +212,8 @@ private function getBlueprint(array $arguments): array $collectionHandle = isset($arguments['collection_handle']) && is_string($arguments['collection_handle']) ? $arguments['collection_handle'] : null; $taxonomyHandle = isset($arguments['taxonomy_handle']) && is_string($arguments['taxonomy_handle']) ? $arguments['taxonomy_handle'] : null; $includeConfig = $this->getBooleanArgument($arguments, 'include_config', true); + $includeFormatSpec = $this->getBooleanArgument($arguments, 'include_format_spec', true); + $maxFormatDepth = $this->getIntegerArgument($arguments, 'max_format_depth', 2, 0, 5); $blueprint = $this->findBlueprint($handle, $namespace, $collectionHandle, $taxonomyHandle); @@ -215,13 +222,15 @@ private function getBlueprint(array $arguments): array return $notFound; } + $formatSpec = $includeFormatSpec ? new FieldFormatSpec($maxFormatDepth) : null; + $data = [ 'handle' => $blueprint->handle(), 'title' => $blueprint->title(), 'namespace' => $blueprint->namespace(), 'hidden' => $blueprint->hidden(), 'order' => $blueprint->order(), - 'fields' => $blueprint->fields()->all()->map(function (mixed $field) use ($includeConfig): array { + 'fields' => $blueprint->fields()->all()->map(function (mixed $field) use ($includeConfig, $formatSpec): array { /** @var Field $field */ $fieldData = [ 'handle' => $field->handle(), @@ -233,6 +242,13 @@ private function getBlueprint(array $arguments): array $fieldData['config'] = $field->config(); } + if ($formatSpec !== null) { + $spec = $formatSpec->for($field); + if ($spec !== null) { + $fieldData['_format_spec'] = $spec; + } + } + return $fieldData; })->toArray(), ]; diff --git a/src/Mcp/Tools/Routers/EntriesRouter.php b/src/Mcp/Tools/Routers/EntriesRouter.php index d92d251..ebc01c5 100644 --- a/src/Mcp/Tools/Routers/EntriesRouter.php +++ b/src/Mcp/Tools/Routers/EntriesRouter.php @@ -414,6 +414,12 @@ private function createEntry(array $arguments): array ]; } catch (\Exception $e) { + // FieldFormatException + ValidationException carry curated messages with + // field paths — they reach the client through this envelope. + // Other Throwables (e.g. TypeError from third-party fieldtype pipelines) + // fall through to BaseStatamicTool::execute() which logs them centrally + // and applies environment-aware sanitization before the message goes + // to the client. return $this->createErrorResponse("Failed to create entry: {$e->getMessage()}")->toArray(); } } @@ -558,6 +564,12 @@ private function updateEntry(array $arguments): array ]; } catch (\Exception $e) { + // FieldFormatException + ValidationException carry curated messages with + // field paths — they reach the client through this envelope. + // Other Throwables (e.g. TypeError from third-party fieldtype pipelines) + // fall through to BaseStatamicTool::execute() which logs them centrally + // and applies environment-aware sanitization before the message goes + // to the client. return $this->createErrorResponse("Failed to update entry: {$e->getMessage()}")->toArray(); } } diff --git a/tests/Integration/BlueprintFormatSpecTest.php b/tests/Integration/BlueprintFormatSpecTest.php new file mode 100644 index 0000000..950dba0 --- /dev/null +++ b/tests/Integration/BlueprintFormatSpecTest.php @@ -0,0 +1,171 @@ +blueprintsRouter = new BlueprintsRouter; + $this->entriesRouter = new EntriesRouter; + + $collection = CollectionFacade::make($this->collectionHandle) + ->title('Format Spec Test'); + $collection->save(); + + $blueprint = BlueprintFacade::make($this->collectionHandle); + $blueprint->setNamespace("collections.{$this->collectionHandle}"); + $blueprint->setContents([ + 'tabs' => [ + 'main' => [ + 'display' => 'Main', + 'sections' => [ + [ + 'fields' => [ + ['handle' => 'title', 'field' => ['type' => 'text', 'display' => 'Title']], + ['handle' => 'tagline', 'field' => [ + 'type' => 'bard', + 'inline' => true, + 'buttons' => ['bold', 'italic'], + 'display' => 'Tagline', + ]], + ['handle' => 'page_builder', 'field' => [ + 'type' => 'replicator', + 'display' => 'Page Builder', + 'sets' => [ + 'main' => ['sets' => [ + 'hero' => [ + 'display' => 'Hero', + 'fields' => [ + ['handle' => 'headline', 'field' => ['type' => 'text']], + ], + ], + ]], + ], + ]], + ], + ], + ], + ], + ], + ]); + $blueprint->save(); + } + + public function test_get_blueprint_includes_format_spec_for_inline_bard(): void + { + $result = $this->blueprintsRouter->execute([ + 'action' => 'get', + 'handle' => $this->collectionHandle, + 'namespace' => 'collections', + 'collection_handle' => $this->collectionHandle, + ]); + + $this->assertTrue($result['success'], 'Expected blueprint get to succeed'); + + $fields = collect($result['data']['blueprint']['fields']); + $tagline = $fields->firstWhere('handle', 'tagline'); + + $this->assertNotNull($tagline, 'tagline field present in blueprint output'); + $this->assertArrayHasKey('_format_spec', $tagline, '_format_spec attached to bard field'); + $this->assertSame('bard_inline', $tagline['_format_spec']['shape']); + $this->assertContains('text', $tagline['_format_spec']['allowed_node_types']); + $this->assertNotContains('paragraph', $tagline['_format_spec']['allowed_node_types']); + } + + public function test_get_blueprint_lists_replicator_set_types_and_definitions(): void + { + $result = $this->blueprintsRouter->execute([ + 'action' => 'get', + 'handle' => $this->collectionHandle, + 'namespace' => 'collections', + 'collection_handle' => $this->collectionHandle, + ]); + + $fields = collect($result['data']['blueprint']['fields']); + $pageBuilder = $fields->firstWhere('handle', 'page_builder'); + + $this->assertSame('replicator_items', $pageBuilder['_format_spec']['shape']); + $this->assertContains('hero', $pageBuilder['_format_spec']['allowed_set_types']); + $this->assertSame(['id', 'type', 'enabled'], $pageBuilder['_format_spec']['item_required_keys']); + $this->assertArrayHasKey('hero', $pageBuilder['_format_spec']['set_definitions']); + } + + public function test_get_blueprint_omits_format_spec_when_disabled(): void + { + $result = $this->blueprintsRouter->execute([ + 'action' => 'get', + 'handle' => $this->collectionHandle, + 'namespace' => 'collections', + 'collection_handle' => $this->collectionHandle, + 'include_format_spec' => false, + ]); + + $fields = collect($result['data']['blueprint']['fields']); + foreach ($fields as $field) { + $this->assertArrayNotHasKey('_format_spec', $field); + } + } + + public function test_replicator_with_string_value_returns_field_path_in_error(): void + { + // Sending a string where the replicator expects an array of items is a classic agent + // mistake. The error must reach the client with the field path so the agent can + // self-correct. + $result = $this->entriesRouter->execute([ + 'action' => 'create', + 'collection' => $this->collectionHandle, + 'data' => [ + 'title' => 'Bad replicator payload', + 'page_builder' => 'this is not an array', + ], + ]); + + $this->assertFalse($result['success'], 'Expected create to fail with malformed replicator data'); + + $errorText = is_array($result['errors'] ?? null) ? implode(' ', $result['errors']) : ''; + $this->assertStringContainsString('page_builder', $errorText, 'Error message includes the field name'); + $this->assertStringContainsString('replicator', $errorText, 'Error message identifies the fieldtype'); + } + + public function test_replicator_with_unknown_set_type_returns_specific_error(): void + { + $result = $this->entriesRouter->execute([ + 'action' => 'create', + 'collection' => $this->collectionHandle, + 'data' => [ + 'title' => 'Unknown set type', + 'page_builder' => [ + ['type' => 'nonexistent_block', 'enabled' => true, 'id' => 'abc12345'], + ], + ], + ]); + + $this->assertFalse($result['success']); + + $errorText = is_array($result['errors'] ?? null) ? implode(' ', $result['errors']) : ''; + $this->assertStringContainsString('nonexistent_block', $errorText); + } +} diff --git a/tests/Integration/ClientSafeExceptionTest.php b/tests/Integration/ClientSafeExceptionTest.php new file mode 100644 index 0000000..141d1d5 --- /dev/null +++ b/tests/Integration/ClientSafeExceptionTest.php @@ -0,0 +1,124 @@ +app['env'] = 'production'; + + $tool = new ThrowingTool( + new FieldFormatException('Field [page_builder.0] expects replicator data as an array, received string.'), + ); + + $result = $tool->execute([]); + + $this->assertFalse($result['success']); + $error = $result['error'] ?? ($result['errors'][0] ?? ''); + $this->assertIsString($error); + $this->assertStringContainsString( + 'Field [page_builder.0] expects replicator data as an array', + $error, + 'FieldFormatException message must reach the client in production' + ); + } + + public function test_unrelated_runtime_exception_is_genericised_in_production(): void + { + $this->app['env'] = 'production'; + + $tool = new ThrowingTool( + new \RuntimeException('Internal database path /var/www/storage/secret-cred-cache exposed'), + ); + + $result = $tool->execute([]); + + $this->assertFalse($result['success']); + $error = $result['error'] ?? ($result['errors'][0] ?? ''); + $this->assertIsString($error); + $this->assertStringNotContainsString('Internal database path', $error); + $this->assertStringNotContainsString('secret-cred-cache', $error); + $this->assertStringContainsString('An error occurred', $error); + } + + public function test_unrelated_type_error_is_genericised_in_production(): void + { + $this->app['env'] = 'production'; + + $tool = new ThrowingTool( + new \TypeError('Argument #1 must be of type string, array given, called in /var/www/vendor/statamic/cms/src/Fieldtypes/Bard.php on line 234'), + ); + + $result = $tool->execute([]); + + $this->assertFalse($result['success']); + $error = $result['error'] ?? ($result['errors'][0] ?? ''); + $this->assertIsString($error); + $this->assertStringNotContainsString('vendor/statamic', $error); + $this->assertStringContainsString('An error occurred', $error); + } + + public function test_field_format_exception_message_also_reaches_client_in_local(): void + { + $this->app['env'] = 'local'; + + $tool = new ThrowingTool(new FieldFormatException('field [foo] expects bard data')); + + $result = $tool->execute([]); + + $error = $result['error'] ?? ($result['errors'][0] ?? ''); + $this->assertStringContainsString('field [foo] expects bard data', is_string($error) ? $error : ''); + } +} + +/** + * Minimal BaseStatamicTool subclass that throws a configurable exception + * from executeInternal so tests can probe the error-handling pipeline + * without depending on a router or fieldtype. + */ +class ThrowingTool extends BaseStatamicTool +{ + public function __construct(private \Throwable $toThrow) {} + + public function name(): string + { + return 'test-throwing-tool'; + } + + public function description(): string + { + return 'Test tool that throws.'; + } + + protected function defineSchema(JsonSchemaContract $schema): array + { + return []; + } + + /** + * @param array $arguments + * + * @return array + */ + protected function executeInternal(array $arguments): array + { + throw $this->toThrow; + } +} diff --git a/tests/Unit/FieldFormatSpecTest.php b/tests/Unit/FieldFormatSpecTest.php new file mode 100644 index 0000000..8177d63 --- /dev/null +++ b/tests/Unit/FieldFormatSpecTest.php @@ -0,0 +1,201 @@ + $type], $config)); +} + +it('returns scalar shape for plain string fieldtypes', function (): void { + $spec = (new FieldFormatSpec)->for(makeField('text')); + + expect($spec)->toMatchArray(['wire_format' => 'string', 'shape' => 'string']); +}); + +it('returns boolean shape for toggle', function (): void { + $spec = (new FieldFormatSpec)->for(makeField('toggle')); + + expect($spec)->toMatchArray(['wire_format' => 'boolean', 'shape' => 'boolean']); +}); + +it('returns markdown shape with strict no-prosemirror rule', function (): void { + $spec = (new FieldFormatSpec)->for(makeField('markdown')); + + expect($spec['wire_format'])->toBe('string'); + expect($spec['shape'])->toBe('markdown'); + expect($spec['rules'])->toContain('Plain markdown string. Supports **bold**, *italic*, [links](url), lists, code, etc.'); + expect(implode(' ', $spec['common_mistakes']))->toContain('ProseMirror'); +}); + +it('describes inline bard without paragraph wrapper', function (): void { + $spec = (new FieldFormatSpec)->for(makeField('bard', [ + 'inline' => true, + 'buttons' => ['bold', 'italic'], + ])); + + expect($spec['shape'])->toBe('bard_inline'); + expect($spec['allowed_node_types'])->toBe(['text', 'hardBreak']); + expect($spec['allowed_marks'])->toContain('bold'); + expect($spec['allowed_marks'])->toContain('italic'); + expect(implode(' ', $spec['common_mistakes']))->toContain('paragraph'); +}); + +it('describes full bard with allowed sets and recursive set definitions', function (): void { + $spec = (new FieldFormatSpec)->for(makeField('bard', [ + 'inline' => false, + 'buttons' => ['h2', 'h3', 'bold', 'anchor'], + 'sets' => [ + 'main' => [ + 'sets' => [ + 'callout' => [ + 'display' => 'Callout', + 'fields' => [ + ['handle' => 'title', 'field' => ['type' => 'text']], + ['handle' => 'content', 'field' => ['type' => 'markdown']], + ], + ], + ], + ], + ], + ])); + + expect($spec['shape'])->toBe('bard_block'); + expect($spec['allowed_set_types'])->toBe(['callout']); + expect($spec['allowed_heading_levels'])->toBe([2, 3]); + expect($spec['allowed_marks'])->toContain('bold'); + expect($spec['allowed_marks'])->toContain('link'); + + expect($spec['set_definitions'])->toHaveKey('callout'); + expect($spec['set_definitions']['callout']['fields'])->toHaveCount(2); + + // Nested markdown field carries its own format spec + $contentField = collect($spec['set_definitions']['callout']['fields']) + ->firstWhere('handle', 'content'); + expect($contentField['_format_spec']['shape'])->toBe('markdown'); +}); + +it('describes replicator with item-shape and set definitions', function (): void { + $spec = (new FieldFormatSpec)->for(makeField('replicator', [ + 'sets' => [ + 'main' => [ + 'sets' => [ + 'hero' => [ + 'display' => 'Hero', + 'fields' => [ + ['handle' => 'headline', 'field' => ['type' => 'text']], + ], + ], + 'cta' => [ + 'display' => 'CTA', + 'fields' => [ + ['handle' => 'button_text', 'field' => ['type' => 'text']], + ], + ], + ], + ], + ], + ])); + + expect($spec['shape'])->toBe('replicator_items'); + expect($spec['allowed_set_types'])->toBe(['hero', 'cta']); + expect($spec['item_required_keys'])->toBe(['id', 'type', 'enabled']); + expect($spec['set_definitions'])->toHaveKeys(['hero', 'cta']); + expect($spec['example'][0])->toMatchArray(['id' => 'a1B2c3D4', 'enabled' => true]); +}); + +it('truncates recursion at max depth so deeply nested sets do not explode the response', function (): void { + $spec = (new FieldFormatSpec(maxDepth: 0))->for(makeField('replicator', [ + 'sets' => [ + 'main' => ['sets' => [ + 'block' => ['fields' => [ + ['handle' => 'nested', 'field' => ['type' => 'text']], + ]], + ]], + ], + ])); + + expect($spec['allowed_set_types'])->toBe(['block']); + // depth=0 means we don't recurse into set definitions + expect($spec)->not->toHaveKey('set_definitions'); +}); + +it('respects max depth even when there are nested replicators inside bard sets', function (): void { + $spec = (new FieldFormatSpec(maxDepth: 1))->for(makeField('bard', [ + 'inline' => false, + 'sets' => [ + 'main' => ['sets' => [ + 'gallery' => ['fields' => [ + [ + 'handle' => 'images', + 'field' => [ + 'type' => 'replicator', + 'sets' => [ + 'main' => ['sets' => [ + 'image' => ['fields' => [ + ['handle' => 'src', 'field' => ['type' => 'assets']], + ]], + ]], + ], + ], + ], + ]], + ]], + ], + ])); + + // Outer bard recurses into 'gallery' + expect($spec['set_definitions']['gallery']['fields'])->toHaveCount(1); + + $imagesField = $spec['set_definitions']['gallery']['fields'][0]; + expect($imagesField['handle'])->toBe('images'); + expect($imagesField['_format_spec']['shape'])->toBe('replicator_items'); + + // Inner replicator's set_definitions should be truncated (depth limit hit) + expect($imagesField['_format_spec'])->not->toHaveKey('set_definitions'); +}); + +it('reports allowed values for select fields', function (): void { + $spec = (new FieldFormatSpec)->for(makeField('select', [ + 'options' => ['draft' => 'Draft', 'published' => 'Published'], + ])); + + expect($spec['shape'])->toBe('enum'); + expect($spec['allowed_values'])->toBe(['draft', 'published']); +}); + +it('reports array shape for multi-select', function (): void { + $spec = (new FieldFormatSpec)->for(makeField('select', [ + 'multiple' => true, + 'options' => ['a', 'b', 'c'], + ])); + + expect($spec['shape'])->toBe('enum_array'); + expect($spec['wire_format'])->toBe('array'); + expect($spec['allowed_values'])->toBe(['a', 'b', 'c']); +}); + +it('describes relationship fields with array of UUIDs', function (): void { + $spec = (new FieldFormatSpec)->for(makeField('entries')); + + expect($spec['shape'])->toBe('relationship_ids'); + expect($spec['wire_format'])->toBe('array'); + expect($spec['item_format'])->toBe('uuid_string'); +}); + +it('describes table fields with cell scalars', function (): void { + $spec = (new FieldFormatSpec)->for(makeField('table')); + + expect($spec['shape'])->toBe('table_rows'); + expect(implode(' ', $spec['rules']))->toContain('cells'); + expect(implode(' ', $spec['common_mistakes']))->toContain('value'); +}); + +it('returns null for unknown fieldtypes so the response stays small', function (): void { + $spec = (new FieldFormatSpec)->for(makeField('some_unknown_addon_fieldtype')); + + expect($spec)->toBeNull(); +});