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(); +});