Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.34% covered (success)
95.34%
225 / 236
75.00% covered (warning)
75.00%
9 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ToolFactory
95.34% covered (success)
95.34%
225 / 236
75.00% covered (warning)
75.00%
9 / 12
39
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%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 filterProperty
76.67% covered (warning)
76.67%
23 / 30
0.00% covered (danger)
0.00%
0 / 1
19.25
 toToolInputSchema
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 createCreateObjectTool
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
4.04
 createModifyObjectTool
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 createRemoveObjectTool
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 createGetObjectTool
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 createListObjectTool
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 createGlobalMethodCallTool
100.00% covered (success)
100.00%
26 / 26
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\Identifiers\KebabCaseSlug;
26use Apie\Core\Metadata\MetadataFactory;
27use Apie\SchemaGenerator\SchemaGenerator;
28use Mcp\Types\ListToolsResult;
29use Mcp\Types\Tool;
30use Mcp\Types\ToolInputSchema;
31
32class ToolFactory
33{
34    private const ALLOWED_FIELD_NAMES = [
35        'required',
36        'title',
37        'description',
38        'nullable',
39        'minItems',
40        'maxItems',
41        'minProperties',
42        'maxProperties',
43        'minLength',
44        'maxLength',
45        'pattern',
46        'example',
47        'default',
48        'minimum',
49        'maximum'
50    ];
51
52    private const MAPPER = [
53        CreateResourceActionDefinition::class => 'createCreateObjectTool',
54        ReplaceResourceActionDefinition::class => 'createCreateObjectTool',
55        ModifyResourceActionDefinition::class => 'createModifyObjectTool',
56        GetResourceActionDefinition::class => 'createGetObjectTool',
57        GetResourceListActionDefinition::class => 'createListObjectTool',
58        RemoveResourceActionDefinition::class => 'createRemoveObjectTool',
59        RunResourceMethodDefinition::class => 'createObjectMethodCallTool',
60        RunGlobalMethodDefinition::class => 'createGlobalMethodCallTool',
61    ];
62
63    public function __construct(
64        private readonly ContextBuilderFactory $contextBuilder,
65        private readonly SchemaGenerator $schemaGenerator,
66        private readonly BoundedContextHashmap $boundedContextHashmap,
67        private readonly ActionDefinitionProvider $actionDefinitionProvider
68    ) {
69    }
70
71    public function createList(): ListToolsResult
72    {
73        $context = $this->contextBuilder->createGeneralContext(
74            [
75                ToolFactory::class => $this,
76                ContextConstants::MCP_SERVER => true,
77                SchemaGenerator::class => $this->schemaGenerator
78            ]
79        );
80        $tools = [];
81        foreach ($this->boundedContextHashmap as $id => $boundedContext) {
82            $subcontext = $context
83                ->withContext(
84                    ContextConstants::BOUNDED_CONTEXT_ID,
85                    $id
86                )
87                ->withContext(
88                    BoundedContext::class,
89                    $boundedContext
90                );
91            foreach ($this->actionDefinitionProvider->provideActionDefinitions($boundedContext, $subcontext) as $routeDefinition) {
92                foreach (self::MAPPER as $className => $methodName) {
93                    if (get_class($routeDefinition) === $className) {
94                        $tools[] = $this->{$methodName}($routeDefinition);
95                    }
96                }
97            }
98        }
99        return new ListToolsResult($tools);
100    }
101
102    public function findByName(string $name): Tool
103    {
104        $all = $this->createList();
105        foreach ($all->tools as $tool) {
106            if ($tool->name === $name) {
107                return $tool;
108            }
109        }
110
111        throw new \LogicException('Tool "' . $name . '" not found!');
112    }
113
114    public function createObjectMethodCallTool(
115        RunResourceMethodDefinition $definition
116    ) {
117        $class = $definition->getResourceName();
118        $method = $definition->getMethod();
119        $name = 'run-object-'
120            . $definition->getBoundedContextId()->toNative()
121            . '-'
122            . KebabCaseSlug::fromClass($method->getDeclaringClass())
123            . '-method-'
124            . KebabCaseSlug::fromClass($method);
125        $data = json_decode(
126            json_encode($this->schemaGenerator->createMethodSchema($method)->getSerializableData()),
127            true
128        );
129        $tool = new Tool(
130            $name,
131            $this->toToolInputSchema(
132                $data
133            ),
134            RunItemMethodAction::getDescription($class, $method)
135        );
136        $tool->_meta = [
137            'x-definition' => RunItemMethodAction::class,
138            'x-method-class' => $method->getDeclaringClass()->name,
139            'x-method' => $method->name,
140            'x-fields' => RunItemMethodAction::getRouteAttributes($class, $method),
141        ];
142
143        return $tool;
144    }
145
146    /**
147     * @see https://ai.google.dev/api/caching#Schema
148     * @param array<string, mixed> $property
149     * @return array<string, mixed>
150     */
151    private function filterProperty(array $property): array
152    {
153        $filtered = [
154            'type' => $property['type'] ?? 'object',
155        ];
156        foreach (self::ALLOWED_FIELD_NAMES as $fieldName) {
157            if (isset($property[$fieldName])) {
158                $filtered[$fieldName] = $property[$fieldName];
159            }
160        }
161        if (isset($property['format']) && $property['format'] === 'date-time') {
162            $filtered['format'] = 'date-time';
163        }
164        if (isset($property['enum'])) {
165            $filtered['enum'] = $property['enum'];
166            $filtered['format'] = 'enum';
167        }
168
169        if ($filtered['type'] === 'object') {
170            $filtered['properties'] = [];
171            foreach ($property['properties'] ?? [] as $key => $subProperty) {
172                $filtered['properties'][$key] = $this->filterProperty($subProperty);
173            }
174        }
175        if ($filtered['type'] === 'array' && isset($property['items'])) {
176            $filtered['items'] = $this->filterProperty($property['items'] ?? []);
177        }
178        if (isset($property['anyOf'])) {
179            $filtered['anyOf'] = [];
180            foreach ($property['anyOf'] as $key => $subProperty) {
181                $filtered['anyOf'][$key] = $this->filterProperty($subProperty);
182            }
183        } elseif (isset($property['oneOf'])) {
184            $filtered['anyOf'] = [];
185            foreach ($property['oneOf'] as $key => $subProperty) {
186                $filtered['anyOf'][$key] = $this->filterProperty($subProperty);
187            }
188        } elseif (isset($property['allOf'])) {
189            $filtered['anyOf'] = [];
190            foreach ($property['allOf'] as $key => $subProperty) {
191                $filtered['anyOf'][$key] = $this->filterProperty($subProperty);
192            }
193        }
194
195        return $filtered;
196    }
197
198    /**
199     * Gemini API is quite strict what it supports and our JSON schema is too accurate.
200     * We have to strip details because of it.
201     *
202     * @param array<string, mixed> $input
203     */
204    public function toToolInputSchema(array $input): ToolInputSchema
205    {
206        $filtered = $this->filterProperty($input);
207        return ToolInputSchema::fromArray(
208            $filtered
209        );
210    }
211
212    public function createCreateObjectTool(CreateResourceActionDefinition|ReplaceResourceActionDefinition $definition): Tool
213    {
214        $class = $definition->getResourceName();
215        $name = ($definition instanceof CreateResourceActionDefinition ? 'create-object-' : 'replace-object-')
216            . $definition->getBoundedContextId()->toNative()
217            . '-'
218            . KebabCaseSlug::fromClass($class);
219        $data = json_decode(
220            json_encode($this->schemaGenerator->createSchema($class->name)->getSerializableData()),
221            true
222        );
223        if ($definition instanceof ReplaceResourceActionDefinition && !isset($data['properties']['id'])) {
224            $data['properties']['id'] = ['type' => 'string'];
225            $data['required'] ??= [];
226            $data['required'][] = 'id';
227        }
228        $tool = new Tool(
229            $name,
230            $this->toToolInputSchema($data),
231            CreateObjectAction::getDescription($class)
232        );
233        $tool->_meta = [
234            "x-definition" => CreateObjectAction::class,
235            "x-fields" => CreateObjectAction::getRouteAttributes($class)
236        ];
237
238        return $tool;
239    }
240
241    public function createModifyObjectTool(ModifyResourceActionDefinition $definition): Tool
242    {
243        $class = $definition->getResourceName();
244        $name = 'modify-object-'
245            . $definition->getBoundedContextId()->toNative()
246            . '-'
247            . KebabCaseSlug::fromClass($class);
248        $data = json_decode(
249            json_encode($this->schemaGenerator->createSchema($class->name)->getSerializableData()),
250            true
251        );
252        $modifiableKeys = MetadataFactory::getModificationMetadata($class, new ApieContext())->getHashmap()->toArray();
253        foreach ($data['properties'] ?? [] as $prop => $value) {
254            if (!isset($modifiableKeys[$prop])) {
255                unset($data['properties'][$prop]);
256            }
257        }
258        if (!isset($data['properties']['id'])) {
259            $data['properties']['id'] = ['type' => 'string'];
260        }
261        $data['required'] = ['id'];
262
263        $tool = new Tool(
264            $name,
265            $this->toToolInputSchema($data),
266            ModifyObjectAction::getDescription($class)
267        );
268        $tool->_meta = [
269            "x-definition" => ModifyObjectAction::class,
270            "x-fields" => ModifyObjectAction::getRouteAttributes($class)
271        ];
272
273        return $tool;
274    }
275
276    public function createRemoveObjectTool(RemoveResourceActionDefinition $definition): Tool
277    {
278        $class = $definition->getResourceName();
279        $name = 'remove-object-'
280            . $definition->getBoundedContextId()->toNative()
281            . '-'
282            . KebabCaseSlug::fromClass($class);
283        $tool = new Tool(
284            $name,
285            $this->toToolInputSchema(
286                [
287                    'type' => 'object',
288                    'properties' => [
289                        'id' => [
290                            'type' => 'string'
291                        ],
292                    ],
293                    'required' => ['id']
294                ]
295            ),
296            RemoveObjectAction::getDescription($class)
297        );
298        $tool->_meta = [
299            "x-definition" => RemoveObjectAction::class,
300            "x-fields" => RemoveObjectAction::getRouteAttributes($class)
301        ];
302
303        return $tool;
304    }
305
306    public function createGetObjectTool(GetResourceActionDefinition $definition): Tool
307    {
308        $class = $definition->getResourceName();
309        $name = 'get-object-'
310            . $definition->getBoundedContextId()->toNative()
311            . '-'
312            . KebabCaseSlug::fromClass($class);
313        $tool = new Tool(
314            $name,
315            $this->toToolInputSchema(
316                [
317                    'type' => 'object',
318                    'properties' => [
319                        'id' => [
320                            'type' => 'string'
321                        ],
322                    ],
323                    'required' => ['id']
324                ]
325            ),
326            GetItemAction::getDescription($class)
327        );
328        $tool->_meta = [
329            "x-definition" => GetItemAction::class,
330            "x-fields" => GetItemAction::getRouteAttributes($class)
331        ];
332
333        return $tool;
334    }
335
336    public function createListObjectTool(GetResourceListActionDefinition $definition): Tool
337    {
338        $class = $definition->getResourceName();
339        $name = 'all-object-'
340            . $definition->getBoundedContextId()->toNative()
341            . '-'
342            . KebabCaseSlug::fromClass($class);
343        $tool = new Tool(
344            $name,
345            $this->toToolInputSchema(
346                [
347                    'type' => 'object',
348                    'properties' => [
349                    ],
350                    'required' => []
351                ]
352            ),
353            GetListAction::getDescription($class)
354        );
355        $tool->_meta = [
356            "x-definition" =>  GetListAction::class,
357            "x-fields" => GetListAction::getRouteAttributes($class)
358        ];
359
360        return $tool;
361    }
362
363    public function createGlobalMethodCallTool(
364        RunGlobalMethodDefinition $definition
365    ) {
366        $method = $definition->getMethod();
367        $class = $method->getDeclaringClass();
368        $name = 'run-global-'
369            . $definition->getBoundedContextId()->toNative()
370            . '-'
371            . KebabCaseSlug::fromClass($class)
372            . '-method-'
373            . KebabCaseSlug::fromClass($method);
374        $data = json_decode(
375            json_encode($this->schemaGenerator->createMethodSchema($method)->getSerializableData()),
376            true
377        );
378        $tool = new Tool(
379            $name,
380            $this->toToolInputSchema(
381                $data
382            ),
383            RunAction::getDescription($class, $method)
384        );
385        $tool->_meta = [
386            "x-definition" => RunAction::class,
387            "x-method-class" => $method->getDeclaringClass()->name,
388            "x-method" => $method->name,
389            "x-fields" => RunAction::getRouteAttributes($class, $method),
390        ];
391
392        return $tool;
393    }
394}