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($mimeTypes)->hasMinCount(1, '"mimeTypes" must not be empty.');
44: Assert::that($mimeTypes)->values()->isNonEmptyString('each "mimeTypes" must be a non-empty string, {type} given.');
45: }
46:
47: #[\Override]
48: public function resolveToolMeta(Tool $tool): ?UiToolMeta
49: {
50: $extras = $tool->meta->extras;
51: $nested = null;
52:
53: if (\array_key_exists(Apps::META_KEY, $extras)) {
54: $meta = $extras[Apps::META_KEY];
55: Assert::that($meta)
56: ->isArray('tool "_meta.ui" must be an array, {type} given.')
57: ->isMap('tool "_meta.ui" must be a string-keyed object.')
58: ;
59: $nested = UiToolMeta::fromArray($meta);
60:
61: if (null !== $nested->resourceUri) {
62: return $nested;
63: }
64: }
65:
66: if (! \array_key_exists(Apps::DEPRECATED_RESOURCE_URI_KEY, $extras)) {
67: return $nested;
68: }
69:
70: $resourceUri = $extras[Apps::DEPRECATED_RESOURCE_URI_KEY];
71: Assert::that($resourceUri)->isNonEmptyString(\sprintf('tool "_meta" key "%s" must be a non-empty string, {type} given.', Apps::DEPRECATED_RESOURCE_URI_KEY));
72:
73: return new UiToolMeta(resourceUri: $resourceUri, visibility: $nested?->visibility);
74: }
75:
76: #[\Override]
77: public function resolveResourceMeta(Resource|ResourceContents $source): ?UiResourceMeta
78: {
79: $extras = $source->meta->extras;
80:
81: if (! \array_key_exists(Apps::META_KEY, $extras)) {
82: return null;
83: }
84:
85: $meta = $extras[Apps::META_KEY];
86: Assert::that($meta)
87: ->isArray('resource "_meta.ui" must be an array, {type} given.')
88: ->isMap('resource "_meta.ui" must be a string-keyed object.')
89: ;
90:
91: return UiResourceMeta::fromArray($meta);
92: }
93:
94: #[\Override]
95: public function findAppTools(ListToolsResult $result): array
96: {
97: $appTools = [];
98:
99: foreach ($result->tools as $tool) {
100: try {
101: $uiMeta = $this->resolveToolMeta($tool);
102: } catch (\InvalidArgumentException) {
103: continue;
104: }
105:
106: if (null !== $uiMeta?->resourceUri) {
107: $appTools[] = new AppTool(tool: $tool, uiMeta: $uiMeta);
108: }
109: }
110:
111: return $appTools;
112: }
113:
114: #[\Override]
115: public function readAppResource(string $uri, ?array $inputResponses = null, ?string $requestState = null): InputRequiredResult|ReadResourceResult
116: {
117: Assert::that($uri)->startsWith(Apps::URI_PREFIX, \sprintf('UI resource "uri" must start with "%s".', Apps::URI_PREFIX));
118:
119: $result = $this->client->readResource($uri, $inputResponses, $requestState);
120:
121: if ($result instanceof InputRequiredResult) {
122: return $result;
123: }
124:
125: if ([] === $result->contents) {
126: throw new RuntimeException(\sprintf(
127: 'UI resource "%s" returned no contents.',
128: SafeDisplay::sanitiseCause($uri),
129: ));
130: }
131:
132: foreach ($result->contents as $contents) {
133: if (! \in_array($contents->mimeType, $this->mimeTypes, true)) {
134: throw new RuntimeException(\sprintf(
135: 'UI resource "%s" returned contents of mime type "%s", expected one of "%s".',
136: SafeDisplay::sanitiseCause($uri),
137: SafeDisplay::sanitise($contents->mimeType ?? ''),
138: implode('", "', $this->mimeTypes),
139: ));
140: }
141: }
142:
143: return $result;
144: }
145: }
146: