Skip to content

Commit 5a6759b

Browse files
authored
MCP Schema Enhancement - Automatic JSON Schema Generation from Laravel (#675)
* fix: actions * Fix styling * fix: adding description and validations for actions and fields to inherit native laravel validations * Fix styling * fix: wip * fix: wip * Fix styling * fix: wip * Fix styling * fix: wip * Fix styling * fix: wip * Fix styling * fix: wip * Fix styling --------- Co-authored-by: binaryk <binaryk@users.noreply.github.com>
1 parent b44d8cc commit 5a6759b

31 files changed

+3708
-343
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
composer update --prefer-stable --prefer-dist --no-interaction
3535
3636
- name: Execute tests
37-
run: ./vendor/bin/testbench package:test --no-coverage
37+
run: composer test
3838

3939
- name: Get next version
4040
id: get_version

src/Actions/Action.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
namespace Binaryk\LaravelRestify\Actions;
44

5+
use Binaryk\LaravelRestify\Actions\Concerns\HasSchemaResolver;
56
use Binaryk\LaravelRestify\Http\Requests\ActionRequest;
67
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
7-
use Binaryk\LaravelRestify\Models\Concerns\HasActionLogs;
8+
use Binaryk\LaravelRestify\MCP\Actions\JsonSchemaFromRulesAction;
89
use Binaryk\LaravelRestify\Restify;
910
use Binaryk\LaravelRestify\Traits\AuthorizedToSee;
1011
use Binaryk\LaravelRestify\Traits\Make;
@@ -16,6 +17,7 @@
1617
use Illuminate\Database\Eloquent\Model;
1718
use Illuminate\Http\JsonResponse;
1819
use Illuminate\Http\Request;
20+
use Illuminate\JsonSchema\JsonSchema;
1921
use Illuminate\Support\Collection;
2022
use Illuminate\Support\Str;
2123
use JsonSerializable;
@@ -29,6 +31,7 @@
2931
abstract class Action implements JsonSerializable
3032
{
3133
use AuthorizedToSee;
34+
use HasSchemaResolver;
3235
use Make;
3336
use ProxiesCanSeeToGate;
3437
use Visibility;
@@ -58,11 +61,21 @@ public static function indexQuery(RestifyRequest $request, $query)
5861
*/
5962
public ?Closure $runCallback = null;
6063

64+
/**
65+
* Action description, usually used in the UI or MCP.
66+
*/
67+
public string $description = '';
68+
6169
public function name()
6270
{
6371
return Restify::humanize($this);
6472
}
6573

74+
public function description(RestifyRequest $request): string
75+
{
76+
return $this->description;
77+
}
78+
6679
/**
6780
* Get the URI key for the action.
6881
*/
@@ -191,11 +204,17 @@ public function skipFieldFill(RestifyRequest $request): bool
191204
return $this->skipFieldFill;
192205
}
193206

207+
public function toolSchema(JsonSchema $schema): array
208+
{
209+
return app(JsonSchemaFromRulesAction::class)($schema, $this->rules());
210+
}
211+
194212
#[ReturnTypeWillChange]
195213
public function jsonSerialize()
196214
{
197215
return array_merge([
198216
'name' => $this->name(),
217+
'description' => $this->description(app(RestifyRequest::class)),
199218
'destructive' => $this instanceof DestructiveAction,
200219
'uriKey' => $this->uriKey(),
201220
'payload' => $this->payload(),
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\Actions\Concerns;
4+
5+
use Binaryk\LaravelRestify\Actions\Action;
6+
use Binaryk\LaravelRestify\MCP\Requests\McpActionRequest;
7+
use Illuminate\JsonSchema\JsonSchema;
8+
9+
/**
10+
* @mixin Action
11+
*/
12+
trait HasSchemaResolver
13+
{
14+
protected function resolveActionSchema(JsonSchema $schema): array
15+
{
16+
$fields = [];
17+
18+
$allRules = $this->rules();
19+
20+
foreach ($allRules as $field => $rules) {
21+
if (str_contains($field, '.*') || str_contains($field, '.*.')) {
22+
continue;
23+
}
24+
25+
// Check if this field has nested rules (e.g., employee.* exists)
26+
$fieldType = $this->guessTypeFromValidationRules($rules, $field, $allRules);
27+
28+
$schemaField = match ($fieldType) {
29+
'boolean' => $schema->boolean(),
30+
'number' => $schema->number(),
31+
'array' => $schema->array(),
32+
default => $schema->string()
33+
};
34+
35+
if ($this->isRequired($rules)) {
36+
$schemaField->required();
37+
}
38+
39+
$fields[$field] = $schemaField;
40+
}
41+
42+
if ($this->isStandalone()) {
43+
return $fields;
44+
}
45+
46+
if ($this->isShownOnIndex(app(McpActionRequest::class), $this->repository)) {
47+
$fields['repositories'] = $schema->array()
48+
->items(
49+
$schema->string()
50+
)
51+
->required()
52+
->description("Array of {$modelName} IDs to run the {$actionName} action on.");
53+
} else {
54+
$fields['id'] = $schema->string()
55+
->description("The ID of the {$modelName} to run the {$actionName} action on.")
56+
->required();
57+
}
58+
}
59+
}

src/Commands/GraphqlGenerateCommand.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace Binaryk\LaravelRestify\Commands;
44

5+
use Binaryk\LaravelRestify\Fields\Field;
6+
use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest;
57
use Binaryk\LaravelRestify\Restify;
68
use Illuminate\Console\Command;
79
use Illuminate\Console\ConfirmableTrait;
@@ -334,14 +336,17 @@ protected function generateInputType(string $repositoryClass, string $typeName):
334336
return "input {$typeName}Input {\n{$fieldsString}\n}";
335337
}
336338

339+
/**
340+
* @param Field $field
341+
*/
337342
protected function mapFieldToGraphQLType($field, bool $isInput = false): string
338343
{
339344
$fieldClass = get_class($field);
340345
$fieldClassName = class_basename($fieldClass);
341346

342347
// Use the field's built-in type guessing if available
343348
if (method_exists($field, 'guessFieldType')) {
344-
$fieldType = $field->guessFieldType();
349+
$fieldType = $field->guessFieldType(app(RepositoryStoreRequest::class));
345350

346351
switch ($fieldType) {
347352
case 'boolean':

src/Fields/Concerns/CanMatch.php

Lines changed: 18 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44

55
use Binaryk\LaravelRestify\Contracts\RestifySearchable;
66
use Binaryk\LaravelRestify\Filters\MatchFilter;
7+
use Binaryk\LaravelRestify\Http\Requests\RepositoryStoreRequest;
78
use Binaryk\LaravelRestify\Http\Requests\RestifyRequest;
9+
use Illuminate\JsonSchema\Types\ArrayType;
10+
use Illuminate\JsonSchema\Types\BooleanType;
11+
use Illuminate\JsonSchema\Types\IntegerType;
12+
use Illuminate\JsonSchema\Types\NumberType;
813

914
trait CanMatch
1015
{
@@ -36,7 +41,10 @@ public function matchable(mixed $column = null, ?string $type = null): self
3641
}
3742

3843
$this->matchableColumn = $column ?? $this->getAttribute();
39-
$this->matchableType = $type ?? $this->guessMatchType();
44+
$this->matchableType = $type ?? $this->guessMatchType(
45+
// we'll use the store request to identify rules and guess types
46+
app(RepositoryStoreRequest::class)
47+
);
4048

4149
return $this;
4250
}
@@ -118,56 +126,15 @@ public function getMatchType(RestifyRequest $request = null): ?string
118126
return $this->matchableType;
119127
}
120128

121-
protected function guessMatchType(): string
129+
protected function guessMatchType(RestifyRequest $request): string
122130
{
123-
// Use field type detection from Field class if available
124-
if (method_exists($this, 'guessFieldType')) {
125-
$fieldType = $this->guessFieldType();
126-
127-
return match ($fieldType) {
128-
'boolean' => RestifySearchable::MATCH_BOOL,
129-
'number' => RestifySearchable::MATCH_INTEGER,
130-
'array' => RestifySearchable::MATCH_ARRAY,
131-
default => RestifySearchable::MATCH_TEXT,
132-
};
133-
}
134-
135-
// Fallback to attribute name patterns
136-
$attribute = $this->getAttribute();
137-
138-
if (! is_string($attribute)) {
139-
return RestifySearchable::MATCH_TEXT;
140-
}
141-
142-
$attribute = strtolower($attribute);
143-
144-
// Boolean patterns
145-
if (preg_match('/^(is_|has_|can_|should_|will_|was_|were_)/', $attribute) ||
146-
in_array($attribute,
147-
['active', 'enabled', 'disabled', 'verified', 'published', 'featured', 'public', 'private'])) {
148-
return RestifySearchable::MATCH_BOOL;
149-
}
150-
151-
// Number patterns
152-
if (preg_match('/_(id|count|number|amount|price|cost|total|sum|quantity|qty)$/', $attribute) ||
153-
in_array($attribute,
154-
['id', 'age', 'year', 'month', 'day', 'hour', 'minute', 'second', 'weight', 'height', 'size'])) {
155-
return RestifySearchable::MATCH_INTEGER;
156-
}
157-
158-
// Date patterns
159-
if (preg_match('/_(at|date|time)$/', $attribute) ||
160-
in_array($attribute,
161-
['created_at', 'updated_at', 'deleted_at', 'published_at', 'birthday', 'date_of_birth'])) {
162-
return RestifySearchable::MATCH_DATETIME;
163-
}
164-
165-
// Array patterns (JSON fields)
166-
if (preg_match('/_(json|data|metadata|config|settings|options|tags)$/', $attribute)) {
167-
return RestifySearchable::MATCH_ARRAY;
168-
}
169-
170-
// Default to text matching
171-
return RestifySearchable::MATCH_TEXT;
131+
$fieldType = $this->guessFieldType($request);
132+
133+
return match (get_class($fieldType)) {
134+
ArrayType::class => RestifySearchable::MATCH_ARRAY,
135+
BooleanType::class => RestifySearchable::MATCH_BOOL,
136+
IntegerType::class, NumberType::class => RestifySearchable::MATCH_INTEGER,
137+
default => 'string',
138+
};
172139
}
173140
}

0 commit comments

Comments
 (0)