Skip to content

Commit 0e6cde2

Browse files
committed
feat(mcp): LLM-friendly filter, search, sort and include descriptions
- MatchFilter: honor setDescription() overrides; rewrite default templates as concise single sentences (grammar + typo fixes) - SearchablesCollection: fix fieldNames() always returning empty (entries are SearchableFilter objects, not strings) - McpIndexTool: omit search/sort schema params when unavailable, list real searchable fields, drop the redundant matcher prefix - McpToolHelpers: append the related repository's purpose one-liner to each relationship in the include documentation
1 parent c40b0d1 commit 0e6cde2

7 files changed

Lines changed: 228 additions & 34 deletions

File tree

src/Filters/MatchFilter.php

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -132,24 +132,22 @@ public function usingClosure(Closure $closure): self
132132

133133
public function description(): string
134134
{
135-
$description = "This is a exact match for $this->column (e.g., $this->column=some_value). It accepts negation by prefixing the column with a hyphen (e.g., -$this->column=some_value). The filter type is string.";
136-
137-
if ($this->getType() === RestifySearchable::MATCH_BETWEEN) {
138-
$description = "This is a range match for $this->column (e.g., $this->column=value1,value2). It accepts negation by prefixing the column with a hyphen (e.g., -$this->column=value1,value2). Accepted values can be any.";
139-
}
140-
141-
if ($this->getType() === RestifySearchable::MATCH_DATETIME) {
142-
$description = "This is a date match for $this->column (e.g., $this->column=YYYY-MM-DD or $this->column=value1,value2 for range). It accepts negation by prefixing the column with a hyphen (e.g., -$this->column=YYYY-MM-DD or -$this->column=value1,value2 for range). Accepted values are date.";
143-
}
144-
145-
if ($this->getType() === RestifySearchable::MATCH_BOOL) {
146-
$description = "This is a boolean match for $this->column (e.g., $this->column=true or $this->column=false). It accepts negation by prefixing the column with a hyphen (e.g., -$this->column=true or -$this->column=false). Accepted values are boolean.";
135+
if ($this->description !== '') {
136+
return $this->description;
147137
}
148138

149-
if ($this->getType() === RestifySearchable::MATCH_ARRAY) {
150-
$description = "This is an array match for $this->column (e.g., $this->column=value1,value2). It accepts negation by prefixing the column with a hyphen (e.g., -$this->column=value1,value2). The values acccepted can be any.";
151-
}
139+
return $this->defaultDescription();
140+
}
152141

153-
return $description;
142+
protected function defaultDescription(): string
143+
{
144+
return match ($this->getType()) {
145+
RestifySearchable::MATCH_BETWEEN => "Range match on $this->column ($this->column=min,max). Prefix with '-' to negate (-$this->column=min,max).",
146+
RestifySearchable::MATCH_DATETIME => "Date match on $this->column ($this->column=YYYY-MM-DD, or $this->column=start,end for a range). Prefix with '-' to negate (-$this->column=YYYY-MM-DD).",
147+
RestifySearchable::MATCH_BOOL, 'boolean' => "Boolean match on $this->column ($this->column=true or $this->column=false). Prefix with '-' to negate (-$this->column=true).",
148+
RestifySearchable::MATCH_ARRAY => "Array match on $this->column ($this->column=value1,value2 matches any listed value). Prefix with '-' to negate (-$this->column=value1,value2).",
149+
RestifySearchable::MATCH_INTEGER, 'number', 'int' => "Exact match on $this->column ($this->column=value). Prefix with '-' to negate (-$this->column=value).",
150+
default => "Exact match on $this->column ($this->column=value). Prefix with '-' to negate (-$this->column=value).",
151+
};
154152
}
155153
}

src/Filters/SearchablesCollection.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,17 @@ public function __construct($items = [])
5555
*/
5656
public function fieldNames(): array
5757
{
58-
return $this->filter(fn ($item) => is_string($item) && ! empty($item))
59-
->unique()
60-
->values()
61-
->toArray();
58+
$columns = [];
59+
60+
foreach ($this->all() as $item) {
61+
$column = $item instanceof Filter ? $item->column() : (is_string($item) ? $item : null);
62+
63+
if (is_string($column) && $column !== '' && $column !== 'unknown') {
64+
$columns[] = $column;
65+
}
66+
}
67+
68+
return array_values(array_unique($columns));
6269
}
6370

6471
/**

src/MCP/Concerns/McpIndexTool.php

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ public static function indexToolSchema(JsonSchema $schema): array
3636
->description(static::formatRelationshipDocumentation(app(McpIndexRequest::class))),
3737
];
3838

39-
$searchableFields = (new SearchablesCollection(static::searchables()))->formatForDocumentation();
39+
$searchableFields = (new SearchablesCollection(static::searchables()))->fieldNames();
4040

41-
$properties['search'] = $schema->string()
42-
->description("Search term to filter $key by name or description. Available searchable fields: {$searchableFields} (e.g., search=term)");
41+
if (! empty($searchableFields)) {
42+
$properties['search'] = $schema->string()
43+
->description('Full-text search across: '.implode(', ', $searchableFields).' (search=term).');
44+
}
4345

4446
$sortOptions = collect(static::sorts())
4547
->map(function ($value, $key) {
@@ -52,26 +54,29 @@ public static function indexToolSchema(JsonSchema $schema): array
5254

5355
return $key;
5456
})
57+
->filter(fn ($option) => is_string($option) && $option !== '')
5558
->values()
5659
->toArray();
5760

58-
$properties['sort'] = $schema->string()
59-
->description("Sorting criteria for the $key. Available options: ".implode(', ',
60-
$sortOptions).' (e.g., sort=field or sort=-field for descending)');
61+
if (! empty($sortOptions)) {
62+
$properties['sort'] = $schema->string()
63+
->description("Sort $key by one of: ".implode(', ', $sortOptions).". Prefix with '-' for descending (sort=field or sort=-field).");
64+
}
6165

6266
MatchesCollection::make(static::matches())
6367
->normalize()
6468
->authorized(app(McpRequest::class))
65-
->each(function (MatchFilter $matchFilter) use ($schema, $key, &$properties) {
69+
->each(function (MatchFilter $matchFilter) use ($schema, &$properties) {
6670
$filterKey = $matchFilter->column();
71+
$description = $matchFilter->description();
6772

6873
$properties[$filterKey] = match ($matchFilter->getType()) {
6974
RestifySearchable::MATCH_INTEGER, 'integer' => $schema->integer()
70-
->description("Filter $key resource. Description: ".$matchFilter->description()),
75+
->description($description),
7176
RestifySearchable::MATCH_BOOL, 'boolean' => $schema->boolean()
72-
->description("Filter $key resource. Description: ".$matchFilter->description()),
77+
->description($description),
7378
default => $schema->string()
74-
->description("Filter $key resource. Description: ".$matchFilter->description())
79+
->description($description)
7580
};
7681
});
7782

src/MCP/Concerns/McpToolHelpers.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ protected static function formatRelationshipDocumentation(McpRequest $request):
5252
if ($repositoryClass) {
5353
$fields = static::getRelationshipFields($repositoryClass, $request);
5454
$fieldsList = implode(', ', $fields);
55-
$documentation .= "- {$relationName} (fields: {$fieldsList})\n";
55+
$purpose = static::getRelationshipPurpose($repositoryClass);
56+
$documentation .= "- {$relationName} (fields: {$fieldsList}){$purpose}\n";
5657
$relationshipClasses[$relationName] = $repositoryClass;
5758
} else {
5859
$documentation .= "- {$relationName}\n";
@@ -102,6 +103,23 @@ protected static function formatRelationshipDocumentation(McpRequest $request):
102103
return $documentation;
103104
}
104105

106+
protected static function getRelationshipPurpose(string $repositoryClass): string
107+
{
108+
if (! is_subclass_of($repositoryClass, Repository::class)) {
109+
return '';
110+
}
111+
112+
$custom = $repositoryClass::$description ?? '';
113+
114+
if (! is_string($custom) || $custom === '') {
115+
return '';
116+
}
117+
118+
$firstSentence = trim(strtok($custom, '.'));
119+
120+
return $firstSentence === '' ? '' : " - {$firstSentence}.";
121+
}
122+
105123
protected static function extractRepositoryClass($relationConfig): ?string
106124
{
107125
// Handle string repository class

tests/Controllers/RepositoryFilterControllerTest.php

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public function test_filters_will_render_placeholder(): void
7979
{
8080
PostRepository::$match = [
8181
'title' => MatchFilter::make()
82-
->setDescription('Sort by title')
82+
->setDescription('Filter posts by their title.')
8383
->setPlaceholder('-title')
8484
->setType('string'),
8585
];
@@ -90,10 +90,27 @@ public function test_filters_will_render_placeholder(): void
9090
->assertJson(function (AssertableJson $json) {
9191
$json
9292
->where('data.0.placeholder', '-title')
93-
->where('data.0.description', 'This is a exact match for title (e.g., title=some_value). It accepts negation by prefixing the column with a hyphen (e.g., -title=some_value). The filter type is string.')
93+
->where('data.0.description', 'Filter posts by their title.')
9494
->where('data.0.type', 'string')
9595
->where('data.0.column', 'title')
9696
->etc();
9797
});
9898
}
99+
100+
public function test_match_filter_renders_default_description_when_not_customized(): void
101+
{
102+
PostRepository::$match = [
103+
'title' => MatchFilter::make()->setType('string'),
104+
];
105+
106+
$this->getJson(PostRepository::route('filters', query: [
107+
'only' => 'matches',
108+
]))
109+
->assertJson(function (AssertableJson $json) {
110+
$json
111+
->where('data.0.description', "Exact match on title (title=value). Prefix with '-' to negate (-title=value).")
112+
->where('data.0.column', 'title')
113+
->etc();
114+
});
115+
}
99116
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\Tests\MCP;
4+
5+
use Binaryk\LaravelRestify\Contracts\RestifySearchable;
6+
use Binaryk\LaravelRestify\Filters\MatchFilter;
7+
use Binaryk\LaravelRestify\MCP\Concerns\HasMcpTools;
8+
use Binaryk\LaravelRestify\Repositories\Repository;
9+
use Binaryk\LaravelRestify\Restify;
10+
use Binaryk\LaravelRestify\Tests\Fixtures\Post\Post;
11+
use Binaryk\LaravelRestify\Tests\IntegrationTestCase;
12+
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
13+
14+
class McpIndexToolSchemaTest extends IntegrationTestCase
15+
{
16+
protected function tearDown(): void
17+
{
18+
Restify::$repositories = [];
19+
20+
parent::tearDown();
21+
}
22+
23+
protected function repositoryWith(array $search = [], array $sort = [], array $match = []): Repository
24+
{
25+
return new class($search, $sort, $match) extends Repository
26+
{
27+
use HasMcpTools;
28+
29+
public static $model = Post::class;
30+
31+
public static array $search = [];
32+
33+
public static array $sort = [];
34+
35+
public static array $match = [];
36+
37+
public function __construct(array $search = [], array $sort = [], array $match = [])
38+
{
39+
self::$search = $search;
40+
self::$sort = $sort;
41+
self::$match = $match;
42+
}
43+
};
44+
}
45+
46+
protected function schema(Repository $repository): array
47+
{
48+
return $repository::indexToolSchema(new JsonSchemaTypeFactory);
49+
}
50+
51+
public function test_search_property_lists_searchable_fields(): void
52+
{
53+
$schema = $this->schema($this->repositoryWith(search: ['id', 'title']));
54+
55+
$this->assertArrayHasKey('search', $schema);
56+
57+
$description = $schema['search']->toArray()['description'];
58+
59+
$this->assertStringContainsString('Full-text search across: id, title', $description);
60+
$this->assertStringNotContainsString('No searchable fields available', $description);
61+
$this->assertStringNotContainsString('by name or description', $description);
62+
}
63+
64+
public function test_search_property_is_omitted_when_no_searchables(): void
65+
{
66+
$schema = $this->schema($this->repositoryWith(search: []));
67+
68+
$this->assertArrayNotHasKey('search', $schema);
69+
}
70+
71+
public function test_sort_property_lists_sort_options(): void
72+
{
73+
$schema = $this->schema($this->repositoryWith(sort: ['title', 'is_active']));
74+
75+
$this->assertArrayHasKey('sort', $schema);
76+
77+
$description = $schema['sort']->toArray()['description'];
78+
79+
$this->assertStringContainsString('title', $description);
80+
$this->assertStringContainsString('is_active', $description);
81+
$this->assertStringContainsString("Prefix with '-' for descending", $description);
82+
}
83+
84+
public function test_matcher_property_uses_description_without_clunky_prefix(): void
85+
{
86+
$schema = $this->schema($this->repositoryWith(match: [
87+
'title' => RestifySearchable::MATCH_TEXT,
88+
]));
89+
90+
$this->assertArrayHasKey('title', $schema);
91+
92+
$description = $schema['title']->toArray()['description'];
93+
94+
$this->assertStringNotContainsString('Description:', $description);
95+
$this->assertStringNotContainsString('resource.', $description);
96+
$this->assertSame(
97+
"Exact match on title (title=value). Prefix with '-' to negate (-title=value).",
98+
$description
99+
);
100+
}
101+
102+
public function test_matcher_property_respects_custom_description(): void
103+
{
104+
$schema = $this->schema($this->repositoryWith(match: [
105+
'title' => MatchFilter::make()
106+
->setType(RestifySearchable::MATCH_TEXT)
107+
->setDescription('Filter posts by their title.'),
108+
]));
109+
110+
$this->assertSame(
111+
'Filter posts by their title.',
112+
$schema['title']->toArray()['description']
113+
);
114+
}
115+
116+
public function test_default_templates_per_match_type(): void
117+
{
118+
$cases = [
119+
RestifySearchable::MATCH_TEXT => "Exact match on col (col=value). Prefix with '-' to negate (-col=value).",
120+
RestifySearchable::MATCH_INTEGER => "Exact match on col (col=value). Prefix with '-' to negate (-col=value).",
121+
RestifySearchable::MATCH_BOOL => "Boolean match on col (col=true or col=false). Prefix with '-' to negate (-col=true).",
122+
RestifySearchable::MATCH_BETWEEN => "Range match on col (col=min,max). Prefix with '-' to negate (-col=min,max).",
123+
RestifySearchable::MATCH_DATETIME => "Date match on col (col=YYYY-MM-DD, or col=start,end for a range). Prefix with '-' to negate (-col=YYYY-MM-DD).",
124+
RestifySearchable::MATCH_ARRAY => "Array match on col (col=value1,value2 matches any listed value). Prefix with '-' to negate (-col=value1,value2).",
125+
];
126+
127+
foreach ($cases as $type => $expected) {
128+
$filter = MatchFilter::make()->setColumn('col')->setType($type);
129+
130+
$this->assertSame($expected, $filter->description(), "Failed default template for [{$type}].");
131+
}
132+
}
133+
134+
public function test_default_templates_have_no_grammar_or_typo_bugs(): void
135+
{
136+
foreach ([
137+
RestifySearchable::MATCH_TEXT,
138+
RestifySearchable::MATCH_BETWEEN,
139+
RestifySearchable::MATCH_DATETIME,
140+
RestifySearchable::MATCH_BOOL,
141+
RestifySearchable::MATCH_ARRAY,
142+
] as $type) {
143+
$description = MatchFilter::make()->setColumn('col')->setType($type)->description();
144+
145+
$this->assertStringNotContainsString('a exact match', $description);
146+
$this->assertStringNotContainsString('acccepted', $description);
147+
}
148+
}
149+
}

tests/MCP/WrapperToolsIntegrationTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ public function mcpAllowsIndex(): bool
514514
$this->assertArrayHasKey('properties', $schema);
515515
$this->assertArrayHasKey('page', $schema['properties']);
516516
$this->assertArrayHasKey('perPage', $schema['properties']);
517-
$this->assertArrayHasKey('search', $schema['properties']);
517+
$this->assertArrayNotHasKey('search', $schema['properties']);
518518
$this->assertArrayHasKey('include', $schema['properties']);
519519

520520
// Assert examples are provided

0 commit comments

Comments
 (0)