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\Schema\Cursor;
19: use Nexus\Mcp\Core\Schema\Enum\CacheScope;
20: use Nexus\Mcp\Core\Schema\Result\CallToolResult;
21: use Nexus\Mcp\Core\Schema\Result\InputRequiredResult;
22: use Nexus\Mcp\Core\Schema\Result\ListToolsResult;
23: use Nexus\Mcp\Core\Schema\Tool\Tool;
24: use Nexus\Mcp\Server\CursorPaginator;
25: use Nexus\Mcp\Server\Exception\ToolNotFoundException;
26: use Nexus\Mcp\Server\Exception\ToolOutputValidationException;
27: use Nexus\Mcp\Server\ServerContext;
28: use Nexus\Mcp\Server\Validation\OpisSchemaValidator;
29: use Nexus\Mcp\Server\Validation\SchemaValidatorInterface;
30:
31: /**
32: * In-memory implementation of `MutableToolStoreInterface`.
33: */
34: final class ToolStore implements MutableToolStoreInterface
35: {
36: private readonly CursorPaginator $paginator;
37:
38: /**
39: * @var list<\Closure(): void>
40: */
41: private array $listChangedListeners = [];
42:
43: /**
44: * @param array<array-key, ToolEntry> $entries
45: */
46: public function __construct(
47: private array $entries = [],
48: int $pageSize = CursorPaginator::DEFAULT_PAGE_SIZE,
49: private readonly SchemaValidatorInterface $validator = new OpisSchemaValidator(),
50: private readonly int $ttlMs = 0,
51: private readonly CacheScope $cacheScope = CacheScope::Private,
52: ) {
53: foreach ($entries as $key => $entry) {
54: // A decimal-int-string name arrives as an int key, so the comparison is on the stringified key.
55: Assert::that($entry->tool->name)->isIdentical(
56: (string) $key,
57: 'Tool store entry key "{other}" must match its tool name "{value}".',
58: );
59: }
60:
61: Assert::that($pageSize)
62: ->isPositiveInt('Tool store page size must be a positive integer, {value} given.')
63: ;
64: Assert::that($ttlMs)
65: ->isNaturalInt('Tool store TTL must be a non-negative integer, {value} given.')
66: ;
67:
68: $this->paginator = new CursorPaginator($pageSize);
69: }
70:
71: #[\Override]
72: public function onListChanged(\Closure $listener): void
73: {
74: $this->listChangedListeners[] = $listener;
75: }
76:
77: #[\Override]
78: public function addTool(Tool $tool, ToolExecutorInterface $executor): void
79: {
80: $this->entries[$tool->name] = new ToolEntry($tool, $executor);
81:
82: $this->announceListChange();
83: }
84:
85: #[\Override]
86: public function removeTool(string $name): bool
87: {
88: if (! \array_key_exists($name, $this->entries)) {
89: return false;
90: }
91:
92: unset($this->entries[$name]);
93:
94: $this->announceListChange();
95:
96: return true;
97: }
98:
99: #[\Override]
100: public function list(?Cursor $cursor): ListToolsResult
101: {
102: $page = $this->paginator->paginate($this->entries, $cursor);
103:
104: return new ListToolsResult(
105: tools: array_map(static fn(ToolEntry $entry): Tool => $entry->tool, $page->entries),
106: ttlMs: $this->ttlMs,
107: cacheScope: $this->cacheScope,
108: nextCursor: $page->nextCursor,
109: );
110: }
111:
112: #[\Override]
113: public function call(string $name, ?array $arguments, ServerContext $context): CallToolResult|InputRequiredResult
114: {
115: $entry = $this->entries[$name] ?? throw new ToolNotFoundException($name, $context->requestId);
116:
117: $tool = $entry->tool;
118:
119: $inputData = null === $arguments || [] === $arguments ? new \stdClass() : $arguments;
120: $inputErrors = $this->validator->validate($inputData, $tool->inputSchema);
121:
122: if ([] !== $inputErrors) {
123: throw new InvalidParamsException(
124: $context->requestId,
125: \sprintf('Invalid arguments for tool "%s": %s', $name, implode('; ', $inputErrors)),
126: );
127: }
128:
129: $result = $entry->executor->execute($arguments, $context);
130:
131: if ($result instanceof InputRequiredResult) {
132: // The round trip is unfinished, so there is no structured output to validate yet.
133: return $result;
134: }
135:
136: if (null !== $tool->outputSchema && true !== $result->isError && null !== $result->structuredContent) {
137: $outputData = [] === $result->structuredContent ? new \stdClass() : $result->structuredContent;
138: $outputErrors = $this->validator->validate($outputData, $tool->outputSchema);
139:
140: if ([] !== $outputErrors) {
141: throw new ToolOutputValidationException($name, $outputErrors);
142: }
143: }
144:
145: return $result;
146: }
147:
148: private function announceListChange(): void
149: {
150: foreach ($this->listChangedListeners as $listener) {
151: $listener();
152: }
153: }
154: }
155: