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\Server\Tool;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Core\Exception\InvalidParamsException;
18: use Nexus\Mcp\Core\SafeDisplay;
19: use Nexus\Mcp\Core\Schema\Cursor;
20: use Nexus\Mcp\Core\Schema\Enum\CacheScope;
21: use Nexus\Mcp\Core\Schema\Result\CallToolResult;
22: use Nexus\Mcp\Core\Schema\Result\InputRequiredResult;
23: use Nexus\Mcp\Core\Schema\Result\ListToolsResult;
24: use Nexus\Mcp\Core\Schema\Tool\Tool;
25: use Nexus\Mcp\Core\Validation\IconSrcValidator;
26: use Nexus\Mcp\Core\Validation\IdentifierNameValidator;
27: use Nexus\Mcp\Server\CursorPaginator;
28: use Nexus\Mcp\Server\Exception\ToolNotFoundException;
29: use Nexus\Mcp\Server\Exception\ToolOutputValidationException;
30: use Nexus\Mcp\Server\ServerContext;
31: use Nexus\Mcp\Server\Validation\OpisSchemaValidator;
32: use Nexus\Mcp\Server\Validation\SchemaValidatorInterface;
33: use Nexus\Mcp\Server\Validation\SchemaViolation;
34:
35: /**
36: * In-memory implementation of `MutableToolStoreInterface`.
37: */
38: final class ToolStore implements MutableToolStoreInterface
39: {
40: private const int MAX_REPORTED_VIOLATIONS = 8;
41:
42: private readonly CursorPaginator $paginator;
43:
44: /**
45: * @var list<\Closure(): void>
46: */
47: private array $listChangedListeners = [];
48:
49: /**
50: * @param array<int|non-empty-string, ToolEntry> $entries
51: */
52: public function __construct(
53: private array $entries = [],
54: int $pageSize = CursorPaginator::DEFAULT_PAGE_SIZE,
55: private readonly SchemaValidatorInterface $validator = new OpisSchemaValidator(),
56: private readonly int $ttlMs = 0,
57: private readonly CacheScope $cacheScope = CacheScope::Private,
58: ) {
59: foreach ($entries as $key => $entry) {
60: IdentifierNameValidator::validate($entry->tool->name, 'tool "name"');
61: IconSrcValidator::validate($entry->tool->icons, 'tool');
62: Assert::that($entry->tool->name)->isIdentical(
63: (string) $key,
64: 'Tool store entry key "{other}" must match its tool name "{value}".',
65: );
66: }
67:
68: Assert::that($pageSize)
69: ->isPositiveInt('Tool store page size must be a positive integer, {value} given.')
70: ;
71: Assert::that($ttlMs)
72: ->isNaturalInt('Tool store TTL must be a non-negative integer, {value} given.')
73: ;
74:
75: $this->paginator = new CursorPaginator($pageSize);
76: }
77:
78: #[\Override]
79: public function onListChanged(\Closure $listener): void
80: {
81: $this->listChangedListeners[] = $listener;
82: }
83:
84: #[\Override]
85: public function addTool(Tool $tool, ToolExecutorInterface $executor): void
86: {
87: IdentifierNameValidator::validate($tool->name, 'tool "name"');
88: IconSrcValidator::validate($tool->icons, 'tool');
89:
90: $this->entries[$tool->name] = new ToolEntry($tool, $executor);
91:
92: $this->announceListChange();
93: }
94:
95: #[\Override]
96: public function removeTool(string $name): bool
97: {
98: if (! \array_key_exists($name, $this->entries)) {
99: return false;
100: }
101:
102: unset($this->entries[$name]);
103:
104: $this->announceListChange();
105:
106: return true;
107: }
108:
109: #[\Override]
110: public function list(?Cursor $cursor): ListToolsResult
111: {
112: $page = $this->paginator->paginate($this->entries, $cursor);
113:
114: return new ListToolsResult(
115: tools: array_map(static fn(ToolEntry $entry): Tool => $entry->tool, $page->entries),
116: ttlMs: $this->ttlMs,
117: cacheScope: $this->cacheScope,
118: nextCursor: $page->nextCursor,
119: );
120: }
121:
122: #[\Override]
123: public function call(string $name, ?array $arguments, ServerContext $context): CallToolResult|InputRequiredResult
124: {
125: $entry = $this->entries[$name] ?? throw new ToolNotFoundException($name, $context->requestId);
126:
127: $tool = $entry->tool;
128:
129: $encoded = $tool->jsonSerialize();
130: $inputData = null === $arguments || [] === $arguments ? new \stdClass() : $arguments;
131:
132: if (\is_array($inputData) && array_is_list($inputData)) {
133: $inputData = (object) $inputData;
134: }
135:
136: $inputErrors = $this->validator->validate($inputData, (array) $encoded['inputSchema']);
137:
138: if ([] !== $inputErrors) {
139: throw new InvalidParamsException(
140: $context->requestId,
141: SafeDisplay::sanitiseCause(
142: \sprintf('Invalid arguments for tool "%s": %s', $name, implode(' ', array_map(
143: static fn(SchemaViolation $violation): string => $violation->message,
144: $inputErrors,
145: ))),
146: ),
147: errorData: ['validation_errors' => $this->describeViolations($inputErrors)],
148: );
149: }
150:
151: try {
152: $result = $entry->executor->execute($arguments, $context);
153: } catch (InvalidParamsException $e) {
154: throw new InvalidParamsException(
155: $context->requestId,
156: SafeDisplay::sanitiseCause(\sprintf('Invalid arguments for tool "%s": %s', $name, $e->getMessage())),
157: );
158: }
159:
160: if ($result instanceof InputRequiredResult) {
161: return $result;
162: }
163:
164: if (null !== $tool->outputSchema && true !== $result->isError) {
165: if (null === $result->structuredContent) {
166: throw new ToolOutputValidationException($name, []);
167: }
168:
169: $outputData = $result->structuredContent;
170:
171: if ([] === $outputData && ! $this->acceptsArray($tool->outputSchema)) {
172: $outputData = new \stdClass();
173: }
174:
175: $outputErrors = $this->validator->validate($outputData, (array) ($encoded['outputSchema'] ?? []));
176:
177: if ([] !== $outputErrors) {
178: throw new ToolOutputValidationException($name, $outputErrors);
179: }
180: }
181:
182: return $result;
183: }
184:
185: /**
186: * @param non-empty-list<SchemaViolation> $violations
187: *
188: * @return non-empty-list<array{pointer: string, message: string}>
189: */
190: private function describeViolations(array $violations): array
191: {
192: $described = [];
193:
194: foreach (\array_slice($violations, 0, self::MAX_REPORTED_VIOLATIONS) as $violation) {
195: $described[] = [
196: 'pointer' => SafeDisplay::sanitiseCause($violation->pointer),
197: 'message' => SafeDisplay::sanitiseCause($violation->message),
198: ];
199: }
200:
201: return $described;
202: }
203:
204: /**
205: * @param array<string, mixed> $schema
206: */
207: private function acceptsArray(array $schema): bool
208: {
209: $type = $schema['type'] ?? null;
210:
211: return 'array' === $type || (\is_array($type) && \in_array('array', $type, true));
212: }
213:
214: private function announceListChange(): void
215: {
216: foreach ($this->listChangedListeners as $listener) {
217: $listener();
218: }
219: }
220: }
221: