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\Skills\Client;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Client\Client;
18: use Nexus\Mcp\Client\Exception\ServerCapabilityNotSupportedException;
19: use Nexus\Mcp\Core\Schema\Cursor;
20: use Nexus\Mcp\Core\Schema\RequestParams\PaginatedRequestParams;
21: use Nexus\Mcp\Core\Schema\Resource\TextResourceContents;
22: use Nexus\Mcp\Core\Schema\Result\InputRequiredResult;
23: use Nexus\Mcp\Core\Schema\Result\ReadResourceResult;
24: use Nexus\Mcp\Extension\Skills\Client\Exception\SkillVerificationFailedException;
25: use Nexus\Mcp\Extension\Skills\Frontmatter;
26: use Nexus\Mcp\Extension\Skills\Schema\Request\GetSkillRequest;
27: use Nexus\Mcp\Extension\Skills\Schema\Request\ListSkillsRequest;
28: use Nexus\Mcp\Extension\Skills\Schema\Request\ReadResourceDirectoryRequest;
29: use Nexus\Mcp\Extension\Skills\Schema\RequestParams\GetSkillRequestParams;
30: use Nexus\Mcp\Extension\Skills\Schema\RequestParams\ReadResourceDirectoryRequestParams;
31: use Nexus\Mcp\Extension\Skills\Schema\Result\ListSkillsResult;
32: use Nexus\Mcp\Extension\Skills\Schema\Result\ReadResourceDirectoryResult;
33: use Nexus\Mcp\Extension\Skills\Schema\ResultResponse\GetSkillResultResponse;
34: use Nexus\Mcp\Extension\Skills\Schema\ResultResponse\ListSkillsResultResponse;
35: use Nexus\Mcp\Extension\Skills\Schema\ResultResponse\ReadResourceDirectoryResultResponse;
36: use Nexus\Mcp\Extension\Skills\Schema\Skill;
37: use Nexus\Mcp\Extension\Skills\Schema\SkillResource;
38: use Nexus\Mcp\Extension\Skills\Skills;
39:
40: /**
41: * Skill-aware client surface over `Client::sendRequest()`.
42: */
43: final readonly class SkillClient implements SkillClientInterface
44: {
45: /**
46: * @param int<1, max> $maxFileBytes Size in bytes above which a file is refused
47: */
48: public function __construct(
49: private Client $client,
50: private int $maxFileBytes = Skills::MAX_BYTES_PER_SKILL,
51: ) {
52: Assert::that($maxFileBytes)->isPositiveInt('Skill client file size limit must be a positive integer, {value} given.');
53: }
54:
55: #[\Override]
56: public function listSkills(?Cursor $cursor = null): ListSkillsResult
57: {
58: $this->readDeclaredSettings(ListSkillsRequest::getMethod());
59:
60: return $this->client->sendRequest(
61: new ListSkillsRequest(
62: id: $this->client->mintRequestId(),
63: params: new PaginatedRequestParams(meta: $this->client->stampMeta(), cursor: $cursor),
64: ),
65: ListSkillsResultResponse::class,
66: )->result;
67: }
68:
69: #[\Override]
70: public function getSkill(string $uri): Skill
71: {
72: $this->readDeclaredSettings(GetSkillRequest::getMethod());
73:
74: return $this->client->sendRequest(
75: new GetSkillRequest(
76: id: $this->client->mintRequestId(),
77: params: new GetSkillRequestParams(uri: $uri, meta: $this->client->stampMeta()),
78: ),
79: GetSkillResultResponse::class,
80: )->result->skill;
81: }
82:
83: #[\Override]
84: public function readSkillFile(Skill $skill, string $uri, ?array $inputResponses = null, ?string $requestState = null): InputRequiredResult|string
85: {
86: $listed = null;
87:
88: if (\is_array($skill->resources)) {
89: $listed = $this->findListedResource($skill->resources, $uri)
90: ?? throw new SkillVerificationFailedException($uri, 'is not listed in the manifest of the skill');
91:
92: if ($listed->size > $this->maxFileBytes) {
93: throw new SkillVerificationFailedException($uri, \sprintf('is listed at %d bytes, above the limit of %d', $listed->size, $this->maxFileBytes));
94: }
95: }
96:
97: $result = $this->client->readResource($uri, $inputResponses, $requestState);
98:
99: if ($result instanceof InputRequiredResult) {
100: return $result;
101: }
102:
103: $bytes = $this->extractBytes($result, $uri);
104:
105: if ($this->maxFileBytes < \strlen($bytes)) {
106: throw new SkillVerificationFailedException($uri, \sprintf('is %d bytes, above the limit of %d', \strlen($bytes), $this->maxFileBytes));
107: }
108:
109: if (null !== $listed) {
110: if (\strlen($bytes) !== $listed->size) {
111: throw new SkillVerificationFailedException($uri, \sprintf('is %d bytes where the manifest lists %d', \strlen($bytes), $listed->size));
112: }
113:
114: if (SkillResource::computeDigest($bytes) !== $listed->digest) {
115: throw new SkillVerificationFailedException($uri, 'does not match the digest listed in the manifest');
116: }
117: }
118:
119: if ($uri === $skill->uri) {
120: $this->verifyFrontmatter($skill, $bytes);
121: }
122:
123: return $bytes;
124: }
125:
126: #[\Override]
127: public function readDirectory(string $uri, ?Cursor $cursor = null): ReadResourceDirectoryResult
128: {
129: $settings = $this->readDeclaredSettings(ReadResourceDirectoryRequest::getMethod());
130:
131: if (true !== ($settings[Skills::DIRECTORY_READ_SETTING] ?? null)) {
132: throw new ServerCapabilityNotSupportedException(ReadResourceDirectoryRequest::getMethod());
133: }
134:
135: return $this->client->sendRequest(
136: new ReadResourceDirectoryRequest(
137: id: $this->client->mintRequestId(),
138: params: new ReadResourceDirectoryRequestParams(uri: $uri, meta: $this->client->stampMeta(), cursor: $cursor),
139: ),
140: ReadResourceDirectoryResultResponse::class,
141: )->result;
142: }
143:
144: /**
145: * The settings declared by the server for the extension, discovering its capabilities first when none are held.
146: *
147: * @return array<string, mixed>
148: *
149: * @throws ServerCapabilityNotSupportedException
150: */
151: private function readDeclaredSettings(string $method): array
152: {
153: $capabilities = $this->client->getServerCapabilities() ?? $this->client->discover()->capabilities;
154:
155: return $capabilities->extensions[Skills::IDENTIFIER] ?? throw new ServerCapabilityNotSupportedException($method);
156: }
157:
158: /**
159: * @param list<SkillResource> $resources
160: */
161: private function findListedResource(array $resources, string $uri): ?SkillResource
162: {
163: foreach ($resources as $resource) {
164: if ($resource->uri === $uri) {
165: return $resource;
166: }
167: }
168:
169: return null;
170: }
171:
172: /**
173: * @throws SkillVerificationFailedException
174: */
175: private function extractBytes(ReadResourceResult $result, string $uri): string
176: {
177: foreach ($result->contents as $contents) {
178: if ($contents->uri !== $uri) {
179: continue;
180: }
181:
182: if ($contents instanceof TextResourceContents) {
183: return $contents->text;
184: }
185:
186: $bytes = base64_decode($contents->blob, true);
187:
188: if (false !== $bytes) {
189: return $bytes;
190: }
191: }
192:
193: throw new SkillVerificationFailedException($uri, 'came back with no readable content');
194: }
195:
196: /**
197: * @throws SkillVerificationFailedException
198: */
199: private function verifyFrontmatter(Skill $skill, string $manifest): void
200: {
201: try {
202: $frontmatter = Frontmatter::parse($manifest);
203: } catch (\InvalidArgumentException) {
204: throw new SkillVerificationFailedException($skill->uri, 'carries no readable frontmatter');
205: }
206:
207: if (! Frontmatter::equals($frontmatter, $skill->frontmatter)) {
208: throw new SkillVerificationFailedException($skill->uri, 'carries frontmatter that differs from the entry');
209: }
210: }
211: }
212: