1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus MCP SDK package.
7: *
8: * (c) 2026 John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Mcp\Core\Schema\Tool;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Core\Schema\Arrayable;
18: use Nexus\Mcp\Core\Schema\BaseMetadata;
19: use Nexus\Mcp\Core\Schema\Icon;
20: use Nexus\Mcp\Core\Schema\Icons;
21: use Nexus\Mcp\Core\Schema\MetaObject;
22: use Nexus\Mcp\Core\Schema\MetaObject\PayloadMetaObject;
23:
24: /**
25: * Definition for a tool the client can call.
26: *
27: * @phpstan-type ToolInputSchemaShape array{type: 'object', ...<string, mixed>}
28: *
29: * @implements Arrayable<array{
30: * name: non-empty-string,
31: * title?: non-empty-string,
32: * description?: non-empty-string,
33: * inputSchema: ToolInputSchemaShape,
34: * outputSchema?: array<string, mixed>,
35: * annotations?: template-type<ToolAnnotations, Arrayable, 'T'>,
36: * icons?: list<template-type<Icon, Arrayable, 'T'>>,
37: * _meta?: template-type<PayloadMetaObject, MetaObject, 'T'>,
38: * }>
39: *
40: * @see https://modelcontextprotocol.io/specification/2026-07-28/schema#tool
41: */
42: final readonly class Tool extends BaseMetadata implements Arrayable, Icons
43: {
44: private const array SUBSCHEMA_MAP_KEYWORDS = ['$defs', 'definitions', 'dependentSchemas', 'patternProperties', 'properties'];
45: private const array SUBSCHEMA_LIST_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'prefixItems'];
46: private const array SINGLE_SUBSCHEMA_KEYWORDS = [
47: 'additionalProperties', 'contains', 'contentSchema', 'else', 'if',
48: 'not', 'propertyNames', 'then', 'unevaluatedItems', 'unevaluatedProperties',
49: ];
50: private const array NON_SUBSCHEMA_OBJECT_KEYWORDS = ['$vocabulary', 'dependentRequired'];
51:
52: /**
53: * @var ToolInputSchemaShape
54: */
55: public array $inputSchema;
56:
57: /**
58: * @var null|array<string, mixed>
59: */
60: public ?array $outputSchema;
61:
62: /**
63: * @param array<string, mixed> $inputSchema
64: * @param null|non-empty-string $description
65: * @param null|array<string, mixed> $outputSchema
66: * @param null|list<Icon> $icons
67: */
68: public function __construct(
69: string $name,
70: array $inputSchema,
71: ?string $title = null,
72: public ?string $description = null,
73: ?array $outputSchema = null,
74: public ToolAnnotations $annotations = new ToolAnnotations(),
75: public ?array $icons = null,
76: public PayloadMetaObject $meta = new PayloadMetaObject(),
77: ) {
78: parent::__construct(name: $name, title: $title);
79:
80: Assert::that($description)->nullOr()->isNonEmptyString('Tool description must be a non-empty string or null.');
81:
82: if (null !== $this->icons) {
83: Assert::that($this->icons)->values()->isInstanceOf(Icon::class);
84: }
85:
86: $this->inputSchema = $this->validateInputSchema($inputSchema);
87: $this->outputSchema = null === $outputSchema ? null : $this->validateOutputSchema($outputSchema);
88: }
89:
90: /**
91: * Inserts `annotations.title` between `title` and `name` per the spec's
92: * Tool-specific fallback rule.
93: *
94: * @return non-empty-string
95: */
96: #[\Override]
97: public function getDisplayName(): string
98: {
99: return $this->title ?? $this->annotations->title ?? $this->name;
100: }
101:
102: #[\Override]
103: public static function fromArray(array $data): static
104: {
105: Assert::that($data)->hasOffset('name', 'Tool data missing "name".');
106: $name = $data['name'];
107: Assert::that($name)->isNonEmptyString('Tool "name" must be a non-empty string, {type} given.');
108:
109: $title = $data['title'] ?? null;
110: Assert::that($title)->nullOr()->isNonEmptyString('Tool "title" must be a non-empty string or null, {type} given.');
111:
112: $description = $data['description'] ?? null;
113: Assert::that($description)->nullOr()->isNonEmptyString('Tool "description" must be a non-empty string or null, {type} given.');
114:
115: Assert::that($data)->hasOffset('inputSchema', 'Tool data missing "inputSchema".');
116: Assert::that($data['inputSchema'])
117: ->isArray('Tool "inputSchema" must be an object, {type} given.')
118: ->isMap('Tool "inputSchema" must be a string-keyed object.')
119: ;
120: $inputSchema = $data['inputSchema'];
121:
122: $outputSchema = null;
123:
124: if (\array_key_exists('outputSchema', $data)) {
125: Assert::that($data['outputSchema'])
126: ->isArray('Tool "outputSchema" must be an object, {type} given.')
127: ->isMap('Tool "outputSchema" must be a string-keyed object.')
128: ;
129: $outputSchema = $data['outputSchema'];
130: }
131:
132: $annotations = new ToolAnnotations();
133:
134: if (\array_key_exists('annotations', $data)) {
135: Assert::that($data['annotations'])
136: ->isArray('Tool "annotations" must be an object, {type} given.')
137: ->isMap('Tool "annotations" must be a string-keyed object.')
138: ;
139: $annotations = ToolAnnotations::fromArray($data['annotations']);
140: }
141:
142: $icons = null;
143:
144: if (isset($data['icons'])) {
145: Assert::that($data['icons'])
146: ->isList('Tool "icons" must be a list, {type} given.')
147: ->values()
148: ->isArray('Tool icon entry must be an object, {type} given.')
149: ->isMap('Tool icon entry must be a string-keyed object.')
150: ;
151: $icons = array_map(Icon::fromArray(...), $data['icons']);
152: }
153:
154: $meta = new PayloadMetaObject();
155:
156: if (\array_key_exists('_meta', $data)) {
157: Assert::that($data['_meta'])
158: ->isArray('Tool "_meta" must be an object, {type} given.')
159: ->not()->isNonEmptyList('Tool "_meta" must be a string-keyed object.')
160: ;
161: $meta = PayloadMetaObject::fromArray($data['_meta']);
162: }
163:
164: return new self(
165: name: $name,
166: inputSchema: $inputSchema,
167: title: $title,
168: description: $description,
169: outputSchema: $outputSchema,
170: annotations: $annotations,
171: icons: $icons,
172: meta: $meta,
173: );
174: }
175:
176: #[\Override]
177: public function toArray(): array
178: {
179: $data = [
180: 'name' => $this->name,
181: 'inputSchema' => $this->inputSchema,
182: ];
183:
184: if (null !== $this->title) {
185: $data['title'] = $this->title;
186: }
187:
188: if (null !== $this->description) {
189: $data['description'] = $this->description;
190: }
191:
192: if (null !== $this->outputSchema) {
193: $data['outputSchema'] = $this->outputSchema;
194: }
195:
196: $annotations = $this->annotations->toArray();
197:
198: if ([] !== $annotations) {
199: $data['annotations'] = $annotations;
200: }
201:
202: if (null !== $this->icons) {
203: $data['icons'] = array_map(static fn(Icon $icon): array => $icon->toArray(), $this->icons);
204: }
205:
206: $meta = $this->meta->toArray();
207:
208: if ([] !== $meta) {
209: $data['_meta'] = $meta;
210: }
211:
212: return $data;
213: }
214:
215: /**
216: * @return array{inputSchema: array<array-key, mixed>, ...<string, mixed>}
217: */
218: #[\Override]
219: public function jsonSerialize(): array
220: {
221: $data = $this->toArray();
222: $data['inputSchema'] = $this->encodeSchema($this->inputSchema);
223:
224: if (null !== $this->outputSchema) {
225: $data['outputSchema'] = $this->encodeSubSchema($this->outputSchema);
226: }
227:
228: return $data;
229: }
230:
231: /**
232: * Re-encodes a JSON Schema so an empty sub-schema emits `{}` rather than `[]`.
233: *
234: * @param array<array-key, mixed> $schema
235: *
236: * @return array<array-key, mixed>
237: */
238: private function encodeSchema(array $schema): array
239: {
240: foreach ($schema as $keyword => $value) {
241: if (! \is_array($value)) {
242: continue;
243: }
244:
245: if (\in_array($keyword, self::SUBSCHEMA_MAP_KEYWORDS, true)) {
246: $mapped = array_map($this->encodeSubSchema(...), $value);
247: $schema[$keyword] = array_is_list($value) ? (object) $mapped : $mapped;
248: } elseif (\in_array($keyword, self::SUBSCHEMA_LIST_KEYWORDS, true)) {
249: $schema[$keyword] = array_map($this->encodeSubSchema(...), $value);
250: } elseif ('items' === $keyword) {
251: $schema[$keyword] = $this->encodeItems($value);
252: } elseif (\in_array($keyword, self::SINGLE_SUBSCHEMA_KEYWORDS, true)) {
253: $schema[$keyword] = $this->encodeSubSchema($value);
254: } elseif (array_is_list($value) && \in_array($keyword, self::NON_SUBSCHEMA_OBJECT_KEYWORDS, true)) {
255: $schema[$keyword] = (object) $value;
256: }
257: }
258:
259: return $schema;
260: }
261:
262: /**
263: * A sub-schema is an object or a boolean, so anything else passes through and a list here can only have
264: * decoded from an object whose names run 0..n-1.
265: */
266: private function encodeSubSchema(mixed $value): mixed
267: {
268: if (! \is_array($value)) {
269: return $value;
270: }
271:
272: $encoded = $this->encodeSchema($value);
273:
274: return array_is_list($value) ? (object) $encoded : $encoded;
275: }
276:
277: /**
278: * Draft-07 also spells `items` as a tuple, so a non-empty list here stays a list.
279: *
280: * @param array<array-key, mixed> $value
281: *
282: * @return array<array-key, mixed>|\stdClass
283: */
284: private function encodeItems(array $value): array|\stdClass
285: {
286: if ([] === $value) {
287: return new \stdClass();
288: }
289:
290: return array_is_list($value)
291: ? array_map($this->encodeSubSchema(...), $value)
292: : $this->encodeSchema($value);
293: }
294:
295: /**
296: * Validates and returns a tool `inputSchema`, whose root must be `type: "object"`.
297: *
298: * @param array<string, mixed> $schema
299: *
300: * @return ToolInputSchemaShape
301: */
302: private function validateInputSchema(array $schema): array
303: {
304: Assert::that($schema)->hasOffset('type', 'tool "inputSchema" missing "type". MCP tool schemas must be objects: add "type" => "object".');
305: Assert::that($schema['type'])->isIdentical('object', 'tool "inputSchema.type" must be {other}, {value} given.');
306: $this->assertSchemaKeywords($schema, 'tool "inputSchema"');
307:
308: return $schema;
309: }
310:
311: /**
312: * @param array<string, mixed> $schema
313: *
314: * @return array<string, mixed>
315: */
316: private function validateOutputSchema(array $schema): array
317: {
318: $this->assertSchemaKeywords($schema, 'tool "outputSchema"');
319:
320: return $schema;
321: }
322:
323: /**
324: * Validates the `$schema`, `properties`, and `required` keywords when present.
325: *
326: * @param array<string, mixed> $schema
327: * @param non-empty-string $context
328: */
329: private function assertSchemaKeywords(array $schema, string $context): void
330: {
331: if (\array_key_exists('$schema', $schema)) {
332: Assert::that($schema['$schema'])->isNonEmptyString(\sprintf('%s "$schema" must be a non-empty string, {type} given.', $context));
333: }
334:
335: if (\array_key_exists('properties', $schema)) {
336: $properties = $schema['properties'];
337: Assert::that($properties)->isArray(\sprintf('%s "properties" must be an object, {type} given.', $context));
338:
339: foreach ($properties as $entry) {
340: if (\is_bool($entry)) {
341: continue;
342: }
343:
344: Assert::that($entry)
345: ->isArray(\sprintf('%s property entry must be an object or boolean, {type} given.', $context))
346: ->isMap(\sprintf('%s property entry must be a string-keyed object.', $context))
347: ;
348: }
349: }
350:
351: if (\array_key_exists('required', $schema)) {
352: Assert::that($schema['required'])
353: ->isList(\sprintf('%s "required" must be a list, got non-list array.', $context))
354: ->values()->isString(\sprintf('%s "required" entry must be a string, {type} given.', $context))
355: ;
356: }
357: }
358: }
359: