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\Extension\Apps\Client;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Client\Client;
18: use Nexus\Mcp\Core\Exception\RuntimeException;
19: use Nexus\Mcp\Core\SafeDisplay;
20: use Nexus\Mcp\Core\Schema\Resource\Resource;
21: use Nexus\Mcp\Core\Schema\Resource\ResourceContents;
22: use Nexus\Mcp\Core\Schema\Result\InputRequiredResult;
23: use Nexus\Mcp\Core\Schema\Result\ListToolsResult;
24: use Nexus\Mcp\Core\Schema\Result\ReadResourceResult;
25: use Nexus\Mcp\Core\Schema\Tool\Tool;
26: use Nexus\Mcp\Extension\Apps\Apps;
27: use Nexus\Mcp\Extension\Apps\Schema\UiResourceMeta;
28: use Nexus\Mcp\Extension\Apps\Schema\UiToolMeta;
29:
30: /**
31: * App-aware client surface for the MCP Apps extension.
32: */
33: final readonly class AppClient implements AppClientInterface
34: {
35: /**
36: * @param list<non-empty-string> $mimeTypes The accepted UI mime types, matching the enabled extension's declaration
37: */
38: public function __construct(
39: private Client $client,
40: private array $mimeTypes = [Apps::MIME_TYPE],
41: ) {
42: Assert::that($mimeTypes)->isList('"mimeTypes" must be a list, {type} given.');
43: Assert::that(\count($mimeTypes))->isPositiveInt('"mimeTypes" must not be empty.');
44: Assert::that($mimeTypes)
45: ->values()
46: ->isNonEmptyString('each "mimeTypes" must be a non-empty string, {type} given.')
47: ;
48: }
49:
50: #[\Override]
51: public function resolveToolMeta(Tool $tool): ?UiToolMeta
52: {
53: $extras = $tool->meta->extras;
54: $nested = null;
55:
56: if (\array_key_exists(Apps::META_KEY, $extras)) {
57: $meta = $extras[Apps::META_KEY];
58: Assert::that($meta)
59: ->isArray('tool "_meta.ui" must be an array, {type} given.')
60: ->isMap('tool "_meta.ui" must be a string-keyed object.')
61: ;
62: $nested = UiToolMeta::fromArray($meta);
63:
64: if (null !== $nested->resourceUri) {
65: return $nested;
66: }
67: }
68:
69: if (! \array_key_exists(Apps::DEPRECATED_RESOURCE_URI_KEY, $extras)) {
70: return $nested;
71: }
72:
73: $resourceUri = $extras[Apps::DEPRECATED_RESOURCE_URI_KEY];
74: Assert::that($resourceUri)->isNonEmptyString(\sprintf('tool "_meta" key "%s" must be a non-empty string, {type} given.', Apps::DEPRECATED_RESOURCE_URI_KEY));
75:
76: return new UiToolMeta(resourceUri: $resourceUri, visibility: $nested?->visibility);
77: }
78:
79: #[\Override]
80: public function resolveResourceMeta(Resource|ResourceContents $source): ?UiResourceMeta
81: {
82: $extras = $source->meta->extras;
83:
84: if (! \array_key_exists(Apps::META_KEY, $extras)) {
85: return null;
86: }
87:
88: $meta = $extras[Apps::META_KEY];
89: Assert::that($meta)
90: ->isArray('resource "_meta.ui" must be an array, {type} given.')
91: ->isMap('resource "_meta.ui" must be a string-keyed object.')
92: ;
93:
94: return UiResourceMeta::fromArray($meta);
95: }
96:
97: #[\Override]
98: public function findAppTools(ListToolsResult $result): array
99: {
100: $appTools = [];
101:
102: foreach ($result->tools as $tool) {
103: try {
104: $uiMeta = $this->resolveToolMeta($tool);
105: } catch (\InvalidArgumentException) {
106: continue;
107: }
108:
109: if (null !== $uiMeta?->resourceUri) {
110: $appTools[] = new AppTool(tool: $tool, uiMeta: $uiMeta);
111: }
112: }
113:
114: return $appTools;
115: }
116:
117: #[\Override]
118: public function readAppResource(string $uri, ?array $inputResponses = null, ?string $requestState = null): InputRequiredResult|ReadResourceResult
119: {
120: Assert::that($uri)->startsWith(Apps::URI_PREFIX, \sprintf('UI resource "uri" must start with "%s".', Apps::URI_PREFIX));
121:
122: $result = $this->client->readResource($uri, $inputResponses, $requestState);
123:
124: if ($result instanceof InputRequiredResult) {
125: return $result;
126: }
127:
128: if ([] === $result->contents) {
129: throw new RuntimeException(\sprintf(
130: 'UI resource "%s" returned no contents.',
131: SafeDisplay::sanitiseCause($uri),
132: ));
133: }
134:
135: foreach ($result->contents as $contents) {
136: if (! \in_array($contents->mimeType, $this->mimeTypes, true)) {
137: throw new RuntimeException(\sprintf(
138: 'UI resource "%s" returned contents of mime type "%s", expected one of "%s".',
139: SafeDisplay::sanitiseCause($uri),
140: SafeDisplay::sanitise($contents->mimeType ?? ''),
141: implode('", "', $this->mimeTypes),
142: ));
143: }
144: }
145:
146: return $result;
147: }
148: }
149: