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