Skip to content

Commit 17c57ff

Browse files
binarykclaude
andcommitted
feat: add MCP wrapper tool system for progressive repository discovery
Implement a 4-tool wrapper system (inspired by Klavis MCP) that reduces MCP tool explosion by wrapping repository operations through progressive discovery. **New Features:** - Add configurable MCP mode (`direct` or `wrapper`) via `RESTIFY_MCP_MODE` env variable - Create 4 wrapper tools for progressive discovery: - `discover-repositories`: List all MCP-enabled repositories - `get-repository-operations`: Get operations for a specific repository - `get-operation-details`: Get detailed schema for an operation - `execute-operation`: Execute an operation with parameters **Architecture:** - `ToolRegistry` service: Centralized registry for repository/operation metadata with caching - `WrapperToolHelpers` trait: Shared utilities for schema formatting and example generation - Multi-layer validation ensures only MCP-enabled repositories are accessible **Benefits:** - Reduces tool count from 50+ to 4 wrapper tools (in wrapper mode) - Better token efficiency for AI agents - Progressive discovery improves exploration - Backward compatible via config (defaults to `direct` mode) - Static tools remain unaffected regardless of mode **Validation:** - Only repositories with `HasMcpTools` trait are exposed - Each operation validates `mcpAllows*()` permissions - Clear error messages guide missing configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8a26d36 commit 17c57ff

File tree

8 files changed

+1372
-0
lines changed

8 files changed

+1372
-0
lines changed

config/restify.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,27 @@
284284
|
285285
*/
286286
'mcp' => [
287+
/*
288+
|--------------------------------------------------------------------------
289+
| MCP Mode
290+
|--------------------------------------------------------------------------
291+
|
292+
| This setting controls how repository operations are exposed to MCP clients.
293+
|
294+
| - 'direct': Each repository operation (index, show, store, etc.) is
295+
| registered as a separate MCP tool. This provides immediate access to
296+
| all operations but can result in many tools.
297+
|
298+
| - 'wrapper': Repository operations are accessed through 4 wrapper tools
299+
| (discover, get operations, get details, execute). This reduces tool
300+
| count and provides progressive discovery but requires multiple calls.
301+
|
302+
| Static tools (like GlobalSearchTool) are always registered directly
303+
| regardless of this setting.
304+
|
305+
*/
306+
'mode' => env('RESTIFY_MCP_MODE', 'direct'),
307+
287308
'tools' => [
288309
'exclude' => [
289310
// Tool classes to exclude from discovery
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
<?php
2+
3+
namespace Binaryk\LaravelRestify\MCP\Concerns;
4+
5+
trait WrapperToolHelpers
6+
{
7+
/**
8+
* Format operation schema for display.
9+
*/
10+
protected function formatSchemaForDisplay(array $schema): array
11+
{
12+
$formatted = [];
13+
14+
foreach ($schema as $key => $value) {
15+
if (is_object($value) && method_exists($value, 'toArray')) {
16+
$formatted[$key] = $value->toArray();
17+
} else {
18+
$formatted[$key] = $value;
19+
}
20+
}
21+
22+
return $formatted;
23+
}
24+
25+
/**
26+
* Generate examples from operation schema.
27+
*/
28+
protected function generateExamplesFromSchema(array $schema, string $operationType): array
29+
{
30+
$examples = [];
31+
32+
switch ($operationType) {
33+
case 'index':
34+
$examples[] = [
35+
'description' => 'Basic pagination',
36+
'parameters' => [
37+
'page' => 1,
38+
'perPage' => 15,
39+
],
40+
];
41+
42+
if (isset($schema['search'])) {
43+
$examples[] = [
44+
'description' => 'Search with pagination',
45+
'parameters' => [
46+
'search' => 'example search term',
47+
'page' => 1,
48+
'perPage' => 15,
49+
],
50+
];
51+
}
52+
53+
if (isset($schema['include'])) {
54+
$examples[] = [
55+
'description' => 'With relationships',
56+
'parameters' => [
57+
'page' => 1,
58+
'perPage' => 15,
59+
'include' => 'posts,comments',
60+
],
61+
];
62+
}
63+
break;
64+
65+
case 'show':
66+
$examples[] = [
67+
'description' => 'Show single record',
68+
'parameters' => [
69+
'id' => '1',
70+
],
71+
];
72+
73+
if (isset($schema['include'])) {
74+
$examples[] = [
75+
'description' => 'Show with relationships',
76+
'parameters' => [
77+
'id' => '1',
78+
'include' => 'posts,comments',
79+
],
80+
];
81+
}
82+
break;
83+
84+
case 'store':
85+
$exampleParams = [];
86+
foreach ($schema as $key => $field) {
87+
if ($key === 'include') {
88+
continue;
89+
}
90+
91+
$exampleParams[$key] = $this->generateExampleValue($key, $field);
92+
}
93+
94+
if (! empty($exampleParams)) {
95+
$examples[] = [
96+
'description' => 'Create new record',
97+
'parameters' => $exampleParams,
98+
];
99+
}
100+
break;
101+
102+
case 'update':
103+
$exampleParams = ['id' => '1'];
104+
foreach ($schema as $key => $field) {
105+
if (in_array($key, ['id', 'include'])) {
106+
continue;
107+
}
108+
109+
$exampleParams[$key] = $this->generateExampleValue($key, $field);
110+
}
111+
112+
if (count($exampleParams) > 1) {
113+
$examples[] = [
114+
'description' => 'Update existing record',
115+
'parameters' => $exampleParams,
116+
];
117+
}
118+
break;
119+
120+
case 'delete':
121+
$examples[] = [
122+
'description' => 'Delete a record',
123+
'parameters' => [
124+
'id' => '1',
125+
],
126+
];
127+
break;
128+
}
129+
130+
return $examples;
131+
}
132+
133+
/**
134+
* Generate example value based on field name and type.
135+
*/
136+
protected function generateExampleValue(string $fieldName, $fieldSchema): mixed
137+
{
138+
if (is_object($fieldSchema) && method_exists($fieldSchema, 'toArray')) {
139+
$fieldArray = $fieldSchema->toArray();
140+
$type = $fieldArray['type'] ?? 'string';
141+
} elseif (is_array($fieldSchema)) {
142+
$type = $fieldSchema['type'] ?? 'string';
143+
} else {
144+
$type = 'string';
145+
}
146+
147+
return match ($type) {
148+
'boolean' => true,
149+
'number', 'integer' => $this->generateNumberExample($fieldName),
150+
'array' => [],
151+
default => $this->generateStringExample($fieldName),
152+
};
153+
}
154+
155+
/**
156+
* Generate number example based on field name.
157+
*/
158+
protected function generateNumberExample(string $fieldName): int|float
159+
{
160+
$fieldName = strtolower($fieldName);
161+
162+
if (str_contains($fieldName, 'price') || str_contains($fieldName, 'amount')) {
163+
return 99.99;
164+
}
165+
166+
if (str_contains($fieldName, 'age')) {
167+
return 25;
168+
}
169+
170+
if (str_contains($fieldName, 'year')) {
171+
return 2024;
172+
}
173+
174+
if (str_ends_with($fieldName, '_id')) {
175+
return 1;
176+
}
177+
178+
return 1;
179+
}
180+
181+
/**
182+
* Generate string example based on field name.
183+
*/
184+
protected function generateStringExample(string $fieldName): string
185+
{
186+
$fieldName = strtolower($fieldName);
187+
188+
if (str_contains($fieldName, 'email')) {
189+
return 'user@example.com';
190+
}
191+
192+
if (str_contains($fieldName, 'name')) {
193+
return 'Example Name';
194+
}
195+
196+
if (str_contains($fieldName, 'title')) {
197+
return 'Example Title';
198+
}
199+
200+
if (str_contains($fieldName, 'description')) {
201+
return 'Example description';
202+
}
203+
204+
if (str_contains($fieldName, 'url')) {
205+
return 'https://example.com';
206+
}
207+
208+
if (str_contains($fieldName, 'phone')) {
209+
return '+1234567890';
210+
}
211+
212+
return 'example value';
213+
}
214+
215+
/**
216+
* Build error response.
217+
*/
218+
protected function buildErrorResponse(string $message, ?string $code = null): array
219+
{
220+
$response = [
221+
'error' => $message,
222+
];
223+
224+
if ($code) {
225+
$response['code'] = $code;
226+
}
227+
228+
return $response;
229+
}
230+
231+
/**
232+
* Build success response.
233+
*/
234+
protected function buildSuccessResponse(array $data, ?string $message = null): array
235+
{
236+
$response = [
237+
'success' => true,
238+
'data' => $data,
239+
];
240+
241+
if ($message) {
242+
$response['message'] = $message;
243+
}
244+
245+
return $response;
246+
}
247+
}

src/MCP/RestifyServer.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,14 @@ protected function discoverTools(): array
125125

126126
protected function discoverRepositoryTools(): void
127127
{
128+
// Check if we should use wrapper mode
129+
if (config('restify.mcp.mode') === 'wrapper') {
130+
$this->registerWrapperTools();
131+
132+
return;
133+
}
134+
135+
// Direct mode - register each operation as a separate tool
128136
collect(Restify::$repositories)
129137
->filter(function (string $repository) {
130138
return in_array(HasMcpTools::class, class_uses_recursive($repository));
@@ -167,6 +175,17 @@ protected function discoverRepositoryTools(): void
167175
});
168176
}
169177

178+
/**
179+
* Register wrapper tools for progressive discovery mode.
180+
*/
181+
protected function registerWrapperTools(): void
182+
{
183+
$this->tools[] = \Binaryk\LaravelRestify\MCP\Tools\Wrapper\DiscoverRepositoriesTool::class;
184+
$this->tools[] = \Binaryk\LaravelRestify\MCP\Tools\Wrapper\GetRepositoryOperationsTool::class;
185+
$this->tools[] = \Binaryk\LaravelRestify\MCP\Tools\Wrapper\GetOperationDetailsTool::class;
186+
$this->tools[] = \Binaryk\LaravelRestify\MCP\Tools\Wrapper\ExecuteOperationTool::class;
187+
}
188+
170189
protected function discoverActionsForRepository(string $repositoryClass, Repository $repositoryInstance): void
171190
{
172191
$actionRequest = app(McpActionRequest::class);

0 commit comments

Comments
 (0)