Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions docs-v3/content/docs/mcp/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<uploaded_file>",
"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
Expand All @@ -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

Expand Down
46 changes: 44 additions & 2 deletions src/Fields/File.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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()
);
}
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 17 additions & 15 deletions src/MCP/Concerns/WrapperToolHelpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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' => [
Expand All @@ -50,7 +52,7 @@ protected function generateExamplesFromSchema(array $schema, string $operationTy
];
}

if (isset($schema['include'])) {
if (isset($properties['include'])) {
$examples[] = [
'description' => 'With relationships',
'parameters' => [
Expand All @@ -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' => [
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
14 changes: 9 additions & 5 deletions tests/MCP/WrapperToolsIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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 = [
Expand Down
Loading