Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.40% covered (success)
96.40%
321 / 333
75.00% covered (warning)
75.00%
9 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ToolFactory
96.40% covered (success)
96.40%
321 / 333
75.00% covered (warning)
75.00%
9 / 12
49
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createList
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 findByName
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 createObjectMethodCallTool
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
 filterProperty
77.78% covered (warning)
77.78%
28 / 36
0.00% covered (danger)
0.00%
0 / 1
22.96
 toToolInputSchema
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 createCreateObjectTool
88.89% covered (warning)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
4.02
 createModifyObjectTool
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
4
 createRemoveObjectTool
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 createGetObjectTool
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 createListObjectTool
100.00% covered (success)
100.00%
89 / 89
100.00% covered (success)
100.00%
1 / 1
8
 createGlobalMethodCallTool
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2namespace Apie\McpServer\Tool;
3
4use Apie\Common\ActionDefinitionProvider;
5use Apie\Common\ActionDefinitions\CreateResourceActionDefinition;
6use Apie\Common\ActionDefinitions\GetResourceActionDefinition;
7use Apie\Common\ActionDefinitions\GetResourceListActionDefinition;
8use Apie\Common\ActionDefinitions\ModifyResourceActionDefinition;
9use Apie\Common\ActionDefinitions\RemoveResourceActionDefinition;
10use Apie\Common\ActionDefinitions\ReplaceResourceActionDefinition;
11use Apie\Common\ActionDefinitions\RunGlobalMethodDefinition;
12use Apie\Common\ActionDefinitions\RunResourceMethodDefinition;
13use Apie\Common\Actions\CreateObjectAction;
14use Apie\Common\Actions\GetItemAction;
15use Apie\Common\Actions\GetListAction;
16use Apie\Common\Actions\ModifyObjectAction;
17use Apie\Common\Actions\RemoveObjectAction;
18use Apie\Common\Actions\RunAction;
19use Apie\Common\Actions\RunItemMethodAction;
20use Apie\Core\BoundedContext\BoundedContext;
21use Apie\Core\BoundedContext\BoundedContextHashmap;
22use Apie\Core\Context\ApieContext;
23use Apie\Core\ContextBuilders\ContextBuilderFactory;
24use Apie\Core\ContextConstants;
25use Apie\Core\Datalayers\ApieDatalayer;
26use Apie\Core\Datalayers\ApieDatalayerWithFilters;
27use Apie\Core\Identifiers\KebabCaseSlug;
28use Apie\Core\Metadata\MetadataFactory;
29use Apie\SchemaGenerator\SchemaGenerator;
30use cebe\openapi\spec\Schema;
31use Mcp\Types\ListToolsResult;
32use Mcp\Types\Tool;
33use Mcp\Types\ToolInputSchema;
34
35class 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}