From aaa9597b3f25bc2d6e17af4de76f9cca53515939 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Fri, 24 Oct 2025 14:20:48 +0300 Subject: [PATCH 1/2] fix: wrapper json schema object --- src/MCP/Concerns/WrapperToolHelpers.php | 32 ++++++++++++----------- tests/MCP/WrapperToolsIntegrationTest.php | 14 ++++++---- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/MCP/Concerns/WrapperToolHelpers.php b/src/MCP/Concerns/WrapperToolHelpers.php index 6df33088..57de4f6d 100644 --- a/src/MCP/Concerns/WrapperToolHelpers.php +++ b/src/MCP/Concerns/WrapperToolHelpers.php @@ -2,32 +2,34 @@ namespace Binaryk\LaravelRestify\MCP\Concerns; +use Illuminate\JsonSchema\JsonSchemaTypeFactory; + trait WrapperToolHelpers { /** * Format operation schema for display. + * + * Wraps the schema properties in an ObjectType before serialization to properly + * handle required fields and other attributes according to JSON Schema spec. + * This matches how Laravel MCP's Tool::toArray() works. */ protected function formatSchemaForDisplay(array $schema): array { - $formatted = []; - - foreach ($schema as $key => $value) { - if (is_object($value) && method_exists($value, 'toArray')) { - $formatted[$key] = $value->toArray(); - } else { - $formatted[$key] = $value; - } - } + $schemaFactory = new JsonSchemaTypeFactory; + $objectType = $schemaFactory->object($schema); - return $formatted; + return $objectType->toArray(); } /** * Generate examples from operation schema. + * + * After wrapping in ObjectType, the schema has a 'properties' key containing all fields. */ protected function generateExamplesFromSchema(array $schema, string $operationType): array { $examples = []; + $properties = $schema['properties'] ?? []; switch ($operationType) { case 'index': @@ -39,7 +41,7 @@ protected function generateExamplesFromSchema(array $schema, string $operationTy ], ]; - if (isset($schema['search'])) { + if (isset($properties['search'])) { $examples[] = [ 'description' => 'Search with pagination', 'parameters' => [ @@ -50,7 +52,7 @@ protected function generateExamplesFromSchema(array $schema, string $operationTy ]; } - if (isset($schema['include'])) { + if (isset($properties['include'])) { $examples[] = [ 'description' => 'With relationships', 'parameters' => [ @@ -70,7 +72,7 @@ protected function generateExamplesFromSchema(array $schema, string $operationTy ], ]; - if (isset($schema['include'])) { + if (isset($properties['include'])) { $examples[] = [ 'description' => 'Show with relationships', 'parameters' => [ @@ -83,7 +85,7 @@ protected function generateExamplesFromSchema(array $schema, string $operationTy case 'store': $exampleParams = []; - foreach ($schema as $key => $field) { + foreach ($properties as $key => $field) { if ($key === 'include') { continue; } @@ -101,7 +103,7 @@ protected function generateExamplesFromSchema(array $schema, string $operationTy case 'update': $exampleParams = ['id' => '1']; - foreach ($schema as $key => $field) { + foreach ($properties as $key => $field) { if (in_array($key, ['id', 'include'])) { continue; } diff --git a/tests/MCP/WrapperToolsIntegrationTest.php b/tests/MCP/WrapperToolsIntegrationTest.php index 3dd42f47..cd048af1 100644 --- a/tests/MCP/WrapperToolsIntegrationTest.php +++ b/tests/MCP/WrapperToolsIntegrationTest.php @@ -441,11 +441,14 @@ public function mcpAllowsIndex(): bool $this->assertArrayHasKey('examples', $resultContent); // Assert schema contains expected fields + // Schema is now wrapped in ObjectType following JSON Schema spec $schema = $resultContent['schema']; - $this->assertArrayHasKey('page', $schema); - $this->assertArrayHasKey('perPage', $schema); - $this->assertArrayHasKey('search', $schema); - $this->assertArrayHasKey('include', $schema); + $this->assertEquals('object', $schema['type']); + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('page', $schema['properties']); + $this->assertArrayHasKey('perPage', $schema['properties']); + $this->assertArrayHasKey('search', $schema['properties']); + $this->assertArrayHasKey('include', $schema['properties']); // Assert examples are provided $this->assertNotEmpty($resultContent['examples']); @@ -729,7 +732,8 @@ public function mcpAllowsStore(): bool $detailsResult = json_decode($detailsResponse->json()['result']['content'][0]['text'], true); $this->assertArrayHasKey('schema', $detailsResult); - $this->assertArrayHasKey('title', $detailsResult['schema']); + // Schema is now wrapped in ObjectType, so check for properties + $this->assertArrayHasKey('properties', $detailsResult['schema']); // Step 4: Execute the store operation $executePayload = [ From 6d5cc6d2f7c64bf288c29f85b698b6b9830edf42 Mon Sep 17 00:00:00 2001 From: Eduard Lupacescu Date: Fri, 24 Oct 2025 15:41:52 +0300 Subject: [PATCH 2/2] fix: adding support for storeAs from the request --- docs-v3/content/docs/mcp/fields.md | 133 +++++++++++++++++++++++++++++ src/Fields/File.php | 46 +++++++++- 2 files changed, 177 insertions(+), 2 deletions(-) diff --git a/docs-v3/content/docs/mcp/fields.md b/docs-v3/content/docs/mcp/fields.md index d7cfb93e..857e50ad 100644 --- a/docs-v3/content/docs/mcp/fields.md +++ b/docs-v3/content/docs/mcp/fields.md @@ -344,6 +344,138 @@ class PostRepository extends Repository } ``` +## File Field with Custom Filenames + +The File field supports custom filenames from request data, perfect for automation workflows like n8n where you want to control the filename during upload. + +### Basic File Upload + +```php +class ExpenseRepository extends Repository +{ + public function fields(RestifyRequest $request): array + { + return [ + field('receipt_path')->file() + ->path('expense_receipts/'.Auth::id()) + ->storeOriginalName('receipt_filename') + ->storeSize('receipt_size') + ->deletable() + ->disk('s3'), + + field('receipt_filename') + ->description('Original filename of the uploaded receipt.'), + + field('receipt_size') + ->description('Size of the uploaded receipt in bytes.'), + ]; + } +} +``` + +### Custom Filename from Request + +Use `storeAs()` with a callback to read the custom filename from the request: + +```php +field('receipt_path')->file() + ->path('expense_receipts/'.Auth::id()) + ->storeAs(fn($request) => $request->input('receipt_filename')) + ->storeOriginalName('receipt_filename') + ->storeSize('receipt_size') + ->deletable() + ->disk('s3'), + +field('receipt_filename') + ->description('Custom filename for the receipt. Provide a meaningful name.'), +``` + +### Smart Extension Handling + +The File field automatically handles file extensions: + +```php +// Request: receipt_filename = "Invoice_Jan_2024" +// Uploaded file: expense.pdf +// Result: Invoice_Jan_2024.pdf (extension auto-appended) + +// Request: receipt_filename = "Invoice_Jan_2024.pdf" +// Uploaded file: expense.pdf +// Result: Invoice_Jan_2024.pdf (used as-is) + +// Request: receipt_filename = "" or null +// Uploaded file: expense.pdf +// Result: a1b2c3d4e5f6.pdf (fallback to auto-generated hash) +``` + +### File Field Behaviors + +**With Callable `storeAs()`:** +```php +->storeAs(fn($request) => $request->input('custom_name')) +``` +- Uses the returned filename for storage +- Uses the same filename for `storeOriginalName()` column +- Auto-appends extension if missing +- Falls back to auto-generated name if returns empty/null + +**With Static `storeAs()`:** +```php +->storeAs('avatar.jpg') +``` +- Uses the static filename for storage +- Uses uploaded file's original name for `storeOriginalName()` column +- Extension must be included in the static string + +**Without `storeAs()`:** +```php +field('receipt')->file() +``` +- Auto-generates hash-based filename +- Uses uploaded file's original name for `storeOriginalName()` column + +### MCP-Optimized File Fields + +Hide file fields from MCP responses to reduce token usage: + +```php +class ExpenseRepository extends Repository +{ + public function fields(RestifyRequest $request): array + { + return [ + field('receipt_path')->file() + ->path('expense_receipts/'.Auth::id()) + ->storeAs(fn($request) => $request->input('receipt_filename')) + ->storeOriginalName('receipt_filename') + ->resolveUsingTemporaryUrl($request->boolean('temporary_urls')) + ->hideFromMcp() + ->description('Only send the absolute URL if you have it, otherwise do not send this field.') + ->disk('s3'), + + field('receipt_filename') + ->description('Filename of the receipt. Keep it descriptive.'), + ]; + } +} +``` + +### File Upload Automation Example + +Perfect for n8n workflows where files are extracted from emails: + +```json +{ + "receipt_path": "", + "receipt_filename": "Invoice_ABC_Company_Jan_2024", + "amount": 1500.00, + "date": "2024-01-15", + "vendor": "ABC Company" +} +``` + +The file will be stored as `expense_receipts/123/Invoice_ABC_Company_Jan_2024.pdf` and the `receipt_filename` column will contain `Invoice_ABC_Company_Jan_2024.pdf`. + ## Best Practices ### 1. Field Selection Strategy @@ -358,6 +490,7 @@ class PostRepository extends Repository - Inline simple relationship data instead of separate API calls - Use computed fields to provide aggregated information - Avoid deeply nested relationship structures +- Hide file fields from MCP using `hideFromMcp()` to save tokens ### 3. Security Considerations diff --git a/src/Fields/File.php b/src/Fields/File.php index 7934d37e..ef073d95 100644 --- a/src/Fields/File.php +++ b/src/Fields/File.php @@ -43,6 +43,20 @@ class File extends Field implements DeletableContract, StorableContract */ public $sizeColumn; + /** + * The custom filename resolved from storeAs callback. + * + * @var string|null + */ + protected $customFilename; + + /** + * Whether to use the custom filename for the original name column. + * + * @var bool + */ + protected $useCustomFilenameForOriginal = false; + /** * The callback that should be executed to store the file. * @@ -188,12 +202,38 @@ protected function storeFile(Request $request, string $requestAttribute) } if (! $this->storeAs) { + $this->customFilename = null; + $this->useCustomFilenameForOriginal = false; + return $file->store($this->getStorageDir(), $this->getStorageDisk()); } + $isCallable = is_callable($this->storeAs); + $filename = $isCallable + ? call_user_func($this->storeAs, $request) + : $this->storeAs; + + // If storeAs returns empty/null, fallback to auto-generated name + if (empty($filename)) { + $this->customFilename = null; + $this->useCustomFilenameForOriginal = false; + + return $file->store($this->getStorageDir(), $this->getStorageDisk()); + } + + // Smart extension handling - append if missing + $extension = $file->getClientOriginalExtension(); + if ($extension && ! str_ends_with(strtolower($filename), '.'.$extension)) { + $filename = $filename.'.'.$extension; + } + + $this->customFilename = $filename; + // Only use custom filename for original name if it came from a callable + $this->useCustomFilenameForOriginal = $isCallable; + return $file->storeAs( $this->getStorageDir(), - is_callable($this->storeAs) ? call_user_func($this->storeAs, $request) : $this->storeAs, + $filename, $this->getStorageDisk() ); } @@ -223,7 +263,9 @@ protected function mergeExtraStorageColumns($request, array $attributes): array $file = $this->resolveFileFromRequest($request); if ($this->originalNameColumn) { - $attributes[$this->originalNameColumn] = $file->getClientOriginalName(); + $attributes[$this->originalNameColumn] = ($this->useCustomFilenameForOriginal && $this->customFilename) + ? $this->customFilename + : $file->getClientOriginalName(); } if ($this->sizeColumn) {