Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.40% |
321 / 333 |
|
75.00% |
9 / 12 |
CRAP | |
0.00% |
0 / 1 |
ToolFactory | |
96.40% |
321 / 333 |
|
75.00% |
9 / 12 |
49 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
createList | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
5 | |||
findByName | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
createObjectMethodCallTool | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
1 | |||
filterProperty | |
77.78% |
28 / 36 |
|
0.00% |
0 / 1 |
22.96 | |||
toToolInputSchema | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
createCreateObjectTool | |
88.89% |
24 / 27 |
|
0.00% |
0 / 1 |
4.02 | |||
createModifyObjectTool | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
4 | |||
createRemoveObjectTool | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
1 | |||
createGetObjectTool | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
1 | |||
createListObjectTool | |
100.00% |
89 / 89 |
|
100.00% |
1 / 1 |
8 | |||
createGlobalMethodCallTool | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | namespace Apie\McpServer\Tool; |
3 | |
4 | use Apie\Common\ActionDefinitionProvider; |
5 | use Apie\Common\ActionDefinitions\CreateResourceActionDefinition; |
6 | use Apie\Common\ActionDefinitions\GetResourceActionDefinition; |
7 | use Apie\Common\ActionDefinitions\GetResourceListActionDefinition; |
8 | use Apie\Common\ActionDefinitions\ModifyResourceActionDefinition; |
9 | use Apie\Common\ActionDefinitions\RemoveResourceActionDefinition; |
10 | use Apie\Common\ActionDefinitions\ReplaceResourceActionDefinition; |
11 | use Apie\Common\ActionDefinitions\RunGlobalMethodDefinition; |
12 | use Apie\Common\ActionDefinitions\RunResourceMethodDefinition; |
13 | use Apie\Common\Actions\CreateObjectAction; |
14 | use Apie\Common\Actions\GetItemAction; |
15 | use Apie\Common\Actions\GetListAction; |
16 | use Apie\Common\Actions\ModifyObjectAction; |
17 | use Apie\Common\Actions\RemoveObjectAction; |
18 | use Apie\Common\Actions\RunAction; |
19 | use Apie\Common\Actions\RunItemMethodAction; |
20 | use Apie\Core\BoundedContext\BoundedContext; |
21 | use Apie\Core\BoundedContext\BoundedContextHashmap; |
22 | use Apie\Core\Context\ApieContext; |
23 | use Apie\Core\ContextBuilders\ContextBuilderFactory; |
24 | use Apie\Core\ContextConstants; |
25 | use Apie\Core\Datalayers\ApieDatalayer; |
26 | use Apie\Core\Datalayers\ApieDatalayerWithFilters; |
27 | use Apie\Core\Identifiers\KebabCaseSlug; |
28 | use Apie\Core\Metadata\MetadataFactory; |
29 | use Apie\SchemaGenerator\SchemaGenerator; |
30 | use cebe\openapi\spec\Schema; |
31 | use Mcp\Types\ListToolsResult; |
32 | use Mcp\Types\Tool; |
33 | use Mcp\Types\ToolInputSchema; |
34 | |
35 | class ToolFactory |
36 | { |
37 | private const ALLOWED_FIELD_NAMES = [ |
38 | 'required', |
39 | 'title', |
40 | 'description', |
41 | 'nullable', |
42 | 'minItems', |
43 | 'maxItems', |
44 | 'minProperties', |
45 | 'maxProperties', |
46 | 'minLength', |
47 | 'maxLength', |
48 | 'pattern', |
49 | 'example', |
50 | 'default', |
51 | 'minimum', |
52 | 'maximum' |
53 | ]; |
54 | |
55 | private const MAPPER = [ |
56 | CreateResourceActionDefinition::class => 'createCreateObjectTool', |
57 | ReplaceResourceActionDefinition::class => 'createCreateObjectTool', |
58 | ModifyResourceActionDefinition::class => 'createModifyObjectTool', |
59 | GetResourceActionDefinition::class => 'createGetObjectTool', |
60 | GetResourceListActionDefinition::class => 'createListObjectTool', |
61 | RemoveResourceActionDefinition::class => 'createRemoveObjectTool', |
62 | RunResourceMethodDefinition::class => 'createObjectMethodCallTool', |
63 | RunGlobalMethodDefinition::class => 'createGlobalMethodCallTool', |
64 | ]; |
65 | |
66 | public function __construct( |
67 | private readonly ContextBuilderFactory $contextBuilder, |
68 | private readonly SchemaGenerator $schemaGenerator, |
69 | private readonly BoundedContextHashmap $boundedContextHashmap, |
70 | private readonly ActionDefinitionProvider $actionDefinitionProvider |
71 | ) { |
72 | } |
73 | |
74 | public function createList(): ListToolsResult |
75 | { |
76 | $context = $this->contextBuilder->createGeneralContext( |
77 | [ |
78 | ToolFactory::class => $this, |
79 | ContextConstants::MCP_SERVER => true, |
80 | SchemaGenerator::class => $this->schemaGenerator |
81 | ] |
82 | ); |
83 | $tools = []; |
84 | foreach ($this->boundedContextHashmap as $id => $boundedContext) { |
85 | $subcontext = $context |
86 | ->withContext( |
87 | ContextConstants::BOUNDED_CONTEXT_ID, |
88 | $id |
89 | ) |
90 | ->withContext( |
91 | BoundedContext::class, |
92 | $boundedContext |
93 | ); |
94 | foreach ($this->actionDefinitionProvider->provideActionDefinitions($boundedContext, $subcontext) as $routeDefinition) { |
95 | foreach (self::MAPPER as $className => $methodName) { |
96 | if (get_class($routeDefinition) === $className) { |
97 | $tools[] = $this->{$methodName}($routeDefinition); |
98 | } |
99 | } |
100 | } |
101 | } |
102 | return new ListToolsResult($tools); |
103 | } |
104 | |
105 | public function findByName(string $name): Tool |
106 | { |
107 | $all = $this->createList(); |
108 | foreach ($all->tools as $tool) { |
109 | if ($tool->name === $name) { |
110 | return $tool; |
111 | } |
112 | } |
113 | |
114 | throw new \LogicException('Tool "' . $name . '" not found!'); |
115 | } |
116 | |
117 | public function createObjectMethodCallTool( |
118 | RunResourceMethodDefinition $definition |
119 | ) { |
120 | $class = $definition->getResourceName(); |
121 | $method = $definition->getMethod(); |
122 | $name = 'run-object-' |
123 | . $definition->getBoundedContextId()->toNative() |
124 | . '-' |
125 | . KebabCaseSlug::fromClass($method->getDeclaringClass()) |
126 | . '-method-' |
127 | . KebabCaseSlug::fromClass($method); |
128 | $data = json_decode( |
129 | json_encode($this->schemaGenerator->createMethodSchema($method)->getSerializableData()), |
130 | true |
131 | ); |
132 | $tool = new Tool( |
133 | $name, |
134 | $this->toToolInputSchema( |
135 | $data |
136 | ), |
137 | RunItemMethodAction::getDescription($class, $method) |
138 | ); |
139 | $tool->_meta = [ |
140 | 'x-definition' => RunItemMethodAction::class, |
141 | 'x-method-class' => $method->getDeclaringClass()->name, |
142 | 'x-method' => $method->name, |
143 | 'x-fields' => [ |
144 | ...RunItemMethodAction::getRouteAttributes($class, $method), |
145 | ContextConstants::APIE_ACTION => RunItemMethodAction::class, |
146 | ], |
147 | "x-bounded-context-id" => $definition->getBoundedContextId()->toNative(), |
148 | ]; |
149 | |
150 | return $tool; |
151 | } |
152 | |
153 | /** |
154 | * @see https://ai.google.dev/api/caching#Schema |
155 | * @param array<string, mixed>|Schema|stdClass $property |
156 | * @return array<string, mixed> |
157 | */ |
158 | private function filterProperty(array|Schema|\stdClass $property): array |
159 | { |
160 | if ($property instanceof Schema) { |
161 | $property = (array) $property->getSerializableData(); |
162 | } |
163 | if ($property instanceof \stdClass) { |
164 | $property = (array) $property; |
165 | } |
166 | $filtered = [ |
167 | 'type' => $property['type'] ?? 'object', |
168 | ]; |
169 | foreach (self::ALLOWED_FIELD_NAMES as $fieldName) { |
170 | if (isset($property[$fieldName])) { |
171 | $filtered[$fieldName] = $property[$fieldName]; |
172 | } |
173 | } |
174 | // Gemini API only supports date-time and enum as formats and fail with 400 bad request otherwise. |
175 | if (isset($property['format']) && $property['format'] === 'date-time') { |
176 | $filtered['format'] = 'date-time'; |
177 | } |
178 | if (isset($property['enum'])) { |
179 | $filtered['enum'] = $property['enum']; |
180 | $filtered['format'] = 'enum'; |
181 | } |
182 | |
183 | if ($filtered['type'] === 'object') { |
184 | $filtered['properties'] = []; |
185 | foreach ($property['properties'] ?? [] as $key => $subProperty) { |
186 | $filtered['properties'][$key] = $this->filterProperty($subProperty); |
187 | } |
188 | if (empty($filtered['properties'])) { |
189 | unset($filtered['properties']); |
190 | } |
191 | } |
192 | if ($filtered['type'] === 'array' && isset($property['items'])) { |
193 | $filtered['items'] = $this->filterProperty($property['items'] ?? []); |
194 | } |
195 | if (isset($property['anyOf'])) { |
196 | $filtered['anyOf'] = []; |
197 | foreach ($property['anyOf'] as $key => $subProperty) { |
198 | $filtered['anyOf'][$key] = $this->filterProperty($subProperty); |
199 | } |
200 | } elseif (isset($property['oneOf'])) { |
201 | $filtered['anyOf'] = []; |
202 | foreach ($property['oneOf'] as $key => $subProperty) { |
203 | $filtered['anyOf'][$key] = $this->filterProperty($subProperty); |
204 | } |
205 | } elseif (isset($property['allOf'])) { |
206 | $filtered['anyOf'] = []; |
207 | foreach ($property['allOf'] as $key => $subProperty) { |
208 | $filtered['anyOf'][$key] = $this->filterProperty($subProperty); |
209 | } |
210 | } |
211 | |
212 | return $filtered; |
213 | } |
214 | |
215 | /** |
216 | * Gemini API is quite strict what it supports and our JSON schema is too accurate. |
217 | * We have to strip details because of it. |
218 | * |
219 | * @param array<string, mixed> $input |
220 | */ |
221 | public function toToolInputSchema(array $input): ToolInputSchema |
222 | { |
223 | $filtered = $this->filterProperty($input); |
224 | return ToolInputSchema::fromArray( |
225 | $filtered |
226 | ); |
227 | } |
228 | |
229 | public function createCreateObjectTool(CreateResourceActionDefinition|ReplaceResourceActionDefinition $definition): Tool |
230 | { |
231 | $class = $definition->getResourceName(); |
232 | $name = ($definition instanceof CreateResourceActionDefinition ? 'create-object-' : 'replace-object-') |
233 | . $definition->getBoundedContextId()->toNative() |
234 | . '-' |
235 | . KebabCaseSlug::fromClass($class); |
236 | $data = json_decode( |
237 | json_encode($this->schemaGenerator->createSchema($class->name)->getSerializableData()), |
238 | true |
239 | ); |
240 | if ($definition instanceof ReplaceResourceActionDefinition && !isset($data['properties']['id'])) { |
241 | $data['properties']['id'] = ['type' => 'string']; |
242 | $data['required'] ??= []; |
243 | $data['required'][] = 'id'; |
244 | } |
245 | $tool = new Tool( |
246 | $name, |
247 | $this->toToolInputSchema($data), |
248 | CreateObjectAction::getDescription($class) |
249 | ); |
250 | $tool->_meta = [ |
251 | "x-definition" => CreateObjectAction::class, |
252 | "x-fields" => [ |
253 | ...CreateObjectAction::getRouteAttributes($class), |
254 | ContextConstants::APIE_ACTION => CreateObjectAction::class, |
255 | ], |
256 | "x-bounded-context-id" => $definition->getBoundedContextId()->toNative(), |
257 | ]; |
258 | |
259 | return $tool; |
260 | } |
261 | |
262 | public function createModifyObjectTool(ModifyResourceActionDefinition $definition): Tool |
263 | { |
264 | $class = $definition->getResourceName(); |
265 | $name = 'modify-object-' |
266 | . $definition->getBoundedContextId()->toNative() |
267 | . '-' |
268 | . KebabCaseSlug::fromClass($class); |
269 | $data = json_decode( |
270 | json_encode($this->schemaGenerator->createSchema($class->name)->getSerializableData()), |
271 | true |
272 | ); |
273 | $modifiableKeys = MetadataFactory::getModificationMetadata($class, new ApieContext())->getHashmap()->toArray(); |
274 | foreach ($data['properties'] ?? [] as $prop => $value) { |
275 | if (!isset($modifiableKeys[$prop])) { |
276 | unset($data['properties'][$prop]); |
277 | } |
278 | } |
279 | if (!isset($data['properties']['id'])) { |
280 | $data['properties']['id'] = ['type' => 'string']; |
281 | } |
282 | $data['required'] = ['id']; |
283 | |
284 | $tool = new Tool( |
285 | $name, |
286 | $this->toToolInputSchema($data), |
287 | ModifyObjectAction::getDescription($class) |
288 | ); |
289 | $tool->_meta = [ |
290 | "x-definition" => ModifyObjectAction::class, |
291 | "x-fields" => [ |
292 | ...ModifyObjectAction::getRouteAttributes($class), |
293 | ContextConstants::APIE_ACTION => ModifyObjectAction::class, |
294 | ], |
295 | "x-bounded-context-id" => $definition->getBoundedContextId()->toNative(), |
296 | ]; |
297 | |
298 | return $tool; |
299 | } |
300 | |
301 | public function createRemoveObjectTool(RemoveResourceActionDefinition $definition): Tool |
302 | { |
303 | $class = $definition->getResourceName(); |
304 | $name = 'remove-object-' |
305 | . $definition->getBoundedContextId()->toNative() |
306 | . '-' |
307 | . KebabCaseSlug::fromClass($class); |
308 | $tool = new Tool( |
309 | $name, |
310 | $this->toToolInputSchema( |
311 | [ |
312 | 'type' => 'object', |
313 | 'properties' => [ |
314 | 'id' => [ |
315 | 'type' => 'string' |
316 | ], |
317 | ], |
318 | 'required' => ['id'] |
319 | ] |
320 | ), |
321 | RemoveObjectAction::getDescription($class) |
322 | ); |
323 | $tool->_meta = [ |
324 | "x-definition" => RemoveObjectAction::class, |
325 | "x-fields" => [ |
326 | ...RemoveObjectAction::getRouteAttributes($class), |
327 | ContextConstants::APIE_ACTION => RemoveObjectAction::class, |
328 | ], |
329 | "x-bounded-context-id" => $definition->getBoundedContextId()->toNative(), |
330 | ]; |
331 | |
332 | return $tool; |
333 | } |
334 | |
335 | public function createGetObjectTool(GetResourceActionDefinition $definition): Tool |
336 | { |
337 | $class = $definition->getResourceName(); |
338 | $name = 'get-object-' |
339 | . $definition->getBoundedContextId()->toNative() |
340 | . '-' |
341 | . KebabCaseSlug::fromClass($class); |
342 | $tool = new Tool( |
343 | $name, |
344 | $this->toToolInputSchema( |
345 | [ |
346 | 'type' => 'object', |
347 | 'properties' => [ |
348 | 'id' => [ |
349 | 'type' => 'string' |
350 | ], |
351 | ], |
352 | 'required' => ['id'] |
353 | ] |
354 | ), |
355 | GetItemAction::getDescription($class) |
356 | ); |
357 | $tool->_meta = [ |
358 | "x-definition" => GetItemAction::class, |
359 | "x-fields" => [ |
360 | ...GetItemAction::getRouteAttributes($class), |
361 | ContextConstants::APIE_ACTION => GetItemAction::class, |
362 | ], |
363 | "x-bounded-context-id" => $definition->getBoundedContextId()->toNative(), |
364 | ]; |
365 | |
366 | return $tool; |
367 | } |
368 | |
369 | public function createListObjectTool(GetResourceListActionDefinition $definition): Tool |
370 | { |
371 | $class = $definition->getResourceName(); |
372 | $name = 'all-object-' |
373 | . $definition->getBoundedContextId()->toNative() |
374 | . '-' |
375 | . KebabCaseSlug::fromClass($class); |
376 | |
377 | $context = $this->contextBuilder->createGeneralContext( |
378 | [ |
379 | ToolFactory::class => $this, |
380 | ContextConstants::MCP_SERVER => true, |
381 | SchemaGenerator::class => $this->schemaGenerator, |
382 | ContextConstants::BOUNDED_CONTEXT_ID => $definition->getBoundedContextId()->toNative(), |
383 | BoundedContext::class => $this->boundedContextHashmap[$definition->getBoundedContextId()->toNative()], |
384 | ] |
385 | ); |
386 | $properties = []; |
387 | $dataLayer = $context->getContext(ApieDatalayer::class, false); |
388 | if ($dataLayer instanceof ApieDatalayerWithFilters) { |
389 | $fieldMetadata = MetadataFactory::getResultMetadata( |
390 | $class, |
391 | $context |
392 | )->getHashmap(); |
393 | $filterColumns = $dataLayer->getFilterColumns($class, $definition->getBoundedContextId()); |
394 | if ($filterColumns !== null) { |
395 | $properties['filters'] = [ |
396 | 'type' => 'object', |
397 | 'properties' => [ |
398 | 'search' => [ |
399 | 'type' => 'string', |
400 | 'minLength' => 1, |
401 | ], |
402 | 'items_per_page' => [ |
403 | 'type' => 'integer', |
404 | 'minimum' => 1 |
405 | ], |
406 | 'page' => [ |
407 | 'type' => 'integer', |
408 | 'minimum' => 0 |
409 | ], |
410 | ], |
411 | 'required' => [] |
412 | ]; |
413 | foreach ($filterColumns as $filterColumn) { |
414 | $schema = new Schema([ |
415 | 'type' => 'string', |
416 | 'minimum' => 1, |
417 | ]); |
418 | if (isset($fieldMetadata[$filterColumn])) { |
419 | $schema = $this->schemaGenerator->createSchema( |
420 | $fieldMetadata[$filterColumn]->allowsNull() ? '?string' : 'string', |
421 | true |
422 | ); |
423 | } |
424 | $properties['filters']['properties'][$filterColumn] = $schema->getSerializableData(); |
425 | } |
426 | } |
427 | $orderByColumns = $dataLayer->getOrderByColumns($class, $definition->getBoundedContextId()); |
428 | if ($orderByColumns?->count()) { |
429 | $values = []; |
430 | foreach ($orderByColumns as $orderByColumn) { |
431 | array_push( |
432 | $values, |
433 | $orderByColumn, |
434 | '+' . $orderByColumn, |
435 | '-' . $orderByColumn |
436 | ); |
437 | } |
438 | $properties['order_by'] = [ |
439 | 'type' => 'array', |
440 | 'items' => [ |
441 | 'type' => 'string', |
442 | 'enum' => $values, |
443 | ] |
444 | ]; |
445 | } |
446 | } |
447 | |
448 | $tool = new Tool( |
449 | $name, |
450 | $this->toToolInputSchema( |
451 | [ |
452 | 'type' => 'object', |
453 | 'properties' => $properties, |
454 | 'required' => [] |
455 | ] |
456 | ), |
457 | GetListAction::getDescription($class) |
458 | ); |
459 | $tool->_meta = [ |
460 | "x-definition" => GetListAction::class, |
461 | "x-fields" => [ |
462 | ...GetListAction::getRouteAttributes($class), |
463 | ContextConstants::APIE_ACTION => GetListAction::class, |
464 | ], |
465 | "x-bounded-context-id" => $definition->getBoundedContextId()->toNative(), |
466 | ]; |
467 | |
468 | return $tool; |
469 | } |
470 | |
471 | public function createGlobalMethodCallTool( |
472 | RunGlobalMethodDefinition $definition |
473 | ) { |
474 | $method = $definition->getMethod(); |
475 | $class = $method->getDeclaringClass(); |
476 | $name = 'run-global-' |
477 | . $definition->getBoundedContextId()->toNative() |
478 | . '-' |
479 | . KebabCaseSlug::fromClass($class) |
480 | . '-method-' |
481 | . KebabCaseSlug::fromClass($method); |
482 | $data = json_decode( |
483 | json_encode($this->schemaGenerator->createMethodSchema($method)->getSerializableData()), |
484 | true |
485 | ); |
486 | $tool = new Tool( |
487 | $name, |
488 | $this->toToolInputSchema( |
489 | $data |
490 | ), |
491 | RunAction::getDescription($class, $method) |
492 | ); |
493 | $tool->_meta = [ |
494 | "x-definition" => RunAction::class, |
495 | "x-method-class" => $method->getDeclaringClass()->name, |
496 | "x-method" => $method->name, |
497 | "x-fields" => [ |
498 | ...RunAction::getRouteAttributes($class, $method), |
499 | ContextConstants::APIE_ACTION => RunAction::class, |
500 | ], |
501 | "x-bounded-context-id" => $definition->getBoundedContextId()->toNative(), |
502 | ]; |
503 | |
504 | return $tool; |
505 | } |
506 | } |