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