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: use Nexus\Mcp\Core\Validation\IdentifierNameValidator;
24:
25: /**
26: * Definition for a tool the client can call.
27: *
28: * @phpstan-type ToolInputSchemaShape array{type: 'object', ...<string, mixed>}
29: *
30: * @implements Arrayable<array{
31: * name: non-empty-string,
32: * title?: non-empty-string,
33: * description?: non-empty-string,
34: * inputSchema: ToolInputSchemaShape,
35: * outputSchema?: array<string, mixed>,
36: * annotations?: template-type<ToolAnnotations, Arrayable, 'T'>,
37: * icons?: list<template-type<Icon, Arrayable, 'T'>>,
38: * _meta?: template-type<PayloadMetaObject, MetaObject, 'T'>,
39: * }>
40: *
41: * @see https://modelcontextprotocol.io/specification/2026-07-28/schema#tool
42: */
43: final readonly class Tool extends BaseMetadata implements Arrayable, Icons
44: {
45: /**
46: * @var null|non-empty-string
47: */
48: public ?string $description;
49:
50: /**
51: * @var ToolInputSchemaShape
52: */
53: public array $inputSchema;
54:
55: /**
56: * @var null|array<string, mixed>
57: */
58: public ?array $outputSchema;
59:
60: /**
61: * @param array<string, mixed> $inputSchema
62: * @param null|array<string, mixed> $outputSchema
63: * @param null|list<Icon> $icons
64: */
65: public function __construct(
66: string $name,
67: array $inputSchema,
68: ?string $title = null,
69: ?string $description = null,
70: ?array $outputSchema = null,
71: public ToolAnnotations $annotations = new ToolAnnotations(),
72: public ?array $icons = null,
73: public PayloadMetaObject $meta = new PayloadMetaObject(),
74: ) {
75: parent::__construct(name: $name, title: $title);
76:
77: IdentifierNameValidator::validate($name, 'tool "name"');
78: Assert::that($description)->nullOr()->isNonEmptyString('Tool description must be a non-empty string or null.');
79:
80: if (null !== $this->icons) {
81: Assert::that($this->icons)->values()->isInstanceOf(Icon::class);
82: }
83:
84: $this->description = $description;
85: $this->inputSchema = self::validateInputSchema($inputSchema);
86: $this->outputSchema = null === $outputSchema ? null : self::validateOutputSchema($outputSchema);
87: }
88:
89: /**
90: * Inserts `annotations.title` between `title` and `name` per the spec's
91: * Tool-specific fallback rule.
92: *
93: * @return non-empty-string
94: */
95: #[\Override]
96: public function getDisplayName(): string
97: {
98: return $this->title ?? $this->annotations->title ?? $this->name;
99: }
100:
101: #[\Override]
102: public static function fromArray(array $data): static
103: {
104: Assert::that($data)->hasOffset('name', 'Tool data missing "name".');
105: $name = $data['name'];
106: Assert::that($name)->isString('Tool "name" must be a string, {type} given.');
107:
108: $title = $data['title'] ?? null;
109: Assert::that($title)->nullOr()->isString('Tool "title" must be a string or null, {type} given.');
110:
111: $description = $data['description'] ?? null;
112: Assert::that($description)->nullOr()->isString('Tool "description" must be a string or null, {type} given.');
113:
114: Assert::that($data)->hasOffset('inputSchema', 'Tool data missing "inputSchema".');
115: Assert::that($data['inputSchema'])
116: ->isArray('Tool "inputSchema" must be an object, {type} given.')
117: ->isMap('Tool "inputSchema" must be a string-keyed object.')
118: ;
119: $inputSchema = $data['inputSchema'];
120:
121: $outputSchema = null;
122:
123: if (\array_key_exists('outputSchema', $data)) {
124: Assert::that($data['outputSchema'])
125: ->isArray('Tool "outputSchema" must be an object, {type} given.')
126: ->isMap('Tool "outputSchema" must be a string-keyed object.')
127: ;
128: $outputSchema = $data['outputSchema'];
129: }
130:
131: $annotations = new ToolAnnotations();
132:
133: if (\array_key_exists('annotations', $data)) {
134: Assert::that($data['annotations'])
135: ->isArray('Tool "annotations" must be an object, {type} given.')
136: ->isMap('Tool "annotations" must be a string-keyed object.')
137: ;
138: $annotations = ToolAnnotations::fromArray($data['annotations']);
139: }
140:
141: $icons = null;
142:
143: if (isset($data['icons'])) {
144: Assert::that($data['icons'])
145: ->isList('Tool "icons" must be a list, {type} given.')
146: ->values()
147: ->isArray('Tool icon entry must be an object, {type} given.')
148: ->isMap('Tool icon entry must be a string-keyed object.')
149: ;
150: $icons = array_map(Icon::fromArray(...), $data['icons']);
151: }
152:
153: $meta = new PayloadMetaObject();
154:
155: if (\array_key_exists('_meta', $data)) {
156: Assert::that($data['_meta'])
157: ->isArray('Tool "_meta" must be an object, {type} given.')
158: ->isMap('Tool "_meta" must be a string-keyed object.')
159: ;
160: $meta = PayloadMetaObject::fromArray($data['_meta']);
161: }
162:
163: return new self(
164: name: $name,
165: inputSchema: $inputSchema,
166: title: $title,
167: description: $description,
168: outputSchema: $outputSchema,
169: annotations: $annotations,
170: icons: $icons,
171: meta: $meta,
172: );
173: }
174:
175: #[\Override]
176: public function toArray(): array
177: {
178: $data = [
179: 'name' => $this->name,
180: 'inputSchema' => $this->inputSchema,
181: ];
182:
183: if (null !== $this->title) {
184: $data['title'] = $this->title;
185: }
186:
187: if (null !== $this->description) {
188: $data['description'] = $this->description;
189: }
190:
191: if (null !== $this->outputSchema) {
192: $data['outputSchema'] = $this->outputSchema;
193: }
194:
195: $annotations = $this->annotations->toArray();
196:
197: if ([] !== $annotations) {
198: $data['annotations'] = $annotations;
199: }
200:
201: if (null !== $this->icons) {
202: $data['icons'] = array_map(static fn(Icon $icon): array => $icon->toArray(), $this->icons);
203: }
204:
205: $meta = $this->meta->toArray();
206:
207: if ([] !== $meta) {
208: $data['_meta'] = $meta;
209: }
210:
211: return $data;
212: }
213:
214: #[\Override]
215: public function jsonSerialize(): array
216: {
217: return $this->toArray();
218: }
219:
220: /**
221: * Validates and returns a tool `inputSchema`. The root must be `type: "object"`.
222: *
223: * @param array<string, mixed> $schema
224: *
225: * @return ToolInputSchemaShape
226: */
227: private static function validateInputSchema(array $schema): array
228: {
229: Assert::that($schema)->hasOffset('type', 'tool "inputSchema" missing "type".');
230: Assert::that($schema['type'])->isIdentical('object', 'tool "inputSchema" "type" must be {other}, {value} given.');
231: self::assertSchemaKeywords($schema, 'tool "inputSchema"');
232:
233: return $schema;
234: }
235:
236: /**
237: * Validates and returns a tool `outputSchema`.
238: *
239: * @param array<string, mixed> $schema
240: *
241: * @return array<string, mixed>
242: */
243: private static function validateOutputSchema(array $schema): array
244: {
245: self::assertSchemaKeywords($schema, 'tool "outputSchema"');
246:
247: return $schema;
248: }
249:
250: /**
251: * Validates the `$schema`, `properties`, and `required` keywords when present.
252: *
253: * @param array<string, mixed> $schema
254: * @param non-empty-string $context
255: */
256: private static function assertSchemaKeywords(array $schema, string $context): void
257: {
258: if (\array_key_exists('$schema', $schema)) {
259: Assert::that($schema['$schema'])->isNonEmptyString(\sprintf('%s "$schema" must be a non-empty string, {type} given.', $context));
260: }
261:
262: if (\array_key_exists('properties', $schema)) {
263: Assert::that($schema['properties'])
264: ->isArray(\sprintf('%s "properties" must be an object, {type} given.', $context))
265: ->isMap(\sprintf('%s "properties" must be a string-keyed object.', $context))
266: ->values()
267: ->isArray(\sprintf('%s property entry must be an object, {type} given.', $context))
268: ->isMap(\sprintf('%s property entry must be a string-keyed object.', $context))
269: ;
270: }
271:
272: if (\array_key_exists('required', $schema)) {
273: Assert::that($schema['required'])
274: ->isList(\sprintf('%s "required" must be a list, got non-list array.', $context))
275: ->values()->isString(\sprintf('%s "required" entry must be a string, {type} given.', $context))
276: ;
277: }
278: }
279: }
280: