Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.34% |
225 / 236 |
|
75.00% |
9 / 12 |
CRAP | |
0.00% |
0 / 1 |
ToolFactory | |
95.34% |
225 / 236 |
|
75.00% |
9 / 12 |
39 | |
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% |
26 / 26 |
|
100.00% |
1 / 1 |
1 | |||
filterProperty | |
76.67% |
23 / 30 |
|
0.00% |
0 / 1 |
19.25 | |||
toToolInputSchema | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
createCreateObjectTool | |
86.96% |
20 / 23 |
|
0.00% |
0 / 1 |
4.04 | |||
createModifyObjectTool | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
4 | |||
createRemoveObjectTool | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
1 | |||
createGetObjectTool | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
1 | |||
createListObjectTool | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
1 | |||
createGlobalMethodCallTool | |
100.00% |
26 / 26 |
|
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\Identifiers\KebabCaseSlug; |
26 | use Apie\Core\Metadata\MetadataFactory; |
27 | use Apie\SchemaGenerator\SchemaGenerator; |
28 | use Mcp\Types\ListToolsResult; |
29 | use Mcp\Types\Tool; |
30 | use Mcp\Types\ToolInputSchema; |
31 | |
32 | class 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 | } |