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\Tasks\Schema\Result;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Core\JsonRpc\InputRequestDispatcher;
18: use Nexus\Mcp\Core\Schema\Enum\ResultType;
19: use Nexus\Mcp\Core\Schema\MetaObject;
20: use Nexus\Mcp\Core\Schema\MetaObject\GenericResultMetaObject;
21: use Nexus\Mcp\Core\Schema\MetaObject\ResultMetaObject;
22: use Nexus\Mcp\Core\Schema\Request\InputRequest;
23: use Nexus\Mcp\Core\Schema\Result;
24: use Nexus\Mcp\Core\Schema\Result\ServerResult;
25: use Nexus\Mcp\Extension\Tasks\Schema\Enum\TaskStatus;
26:
27: /**
28: * The current state of a task, one envelope shape discriminated by `status`.
29: *
30: * @extends Result<array{
31: * _meta?: template-type<ResultMetaObject, MetaObject, 'T'>,
32: * resultType: non-empty-string,
33: * taskId: non-empty-string,
34: * status: non-empty-string,
35: * createdAt: non-empty-string,
36: * lastUpdatedAt: non-empty-string,
37: * ttlMs: null|int,
38: * statusMessage?: non-empty-string,
39: * pollIntervalMs?: int,
40: * result?: array<array-key, mixed>,
41: * error?: array<array-key, mixed>,
42: * inputRequests?: array<int|non-empty-string, array<string, mixed>>,
43: * }>
44: *
45: * @see https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md
46: */
47: final readonly class GetTaskResult extends Result implements ServerResult
48: {
49: /**
50: * @var null|array<int|non-empty-string, InputRequest>
51: */
52: public ?array $inputRequests;
53:
54: /**
55: * @param non-empty-string $taskId
56: * @param non-empty-string $createdAt
57: * @param non-empty-string $lastUpdatedAt
58: * @param null|array<array-key, mixed> $result
59: * @param null|array<array-key, mixed> $error
60: * @param null|array<int|non-empty-string, InputRequest> $inputRequests
61: * @param null|non-empty-string $statusMessage
62: */
63: public function __construct(
64: public string $taskId,
65: public TaskStatus $status,
66: public string $createdAt,
67: public string $lastUpdatedAt,
68: public ?int $ttlMs,
69: public ?array $result = null,
70: public ?array $error = null,
71: ?array $inputRequests = null,
72: public ?string $statusMessage = null,
73: public ?int $pollIntervalMs = null,
74: ResultMetaObject $meta = new GenericResultMetaObject(),
75: ) {
76: if (null !== $result) {
77: Assert::that($result)->not()->isNonEmptyList('"result.result" must be a string-keyed object.');
78: }
79:
80: if (null !== $error) {
81: Assert::that($error)->not()->isNonEmptyList('"result.error" must be a string-keyed object.');
82: }
83:
84: $inputRequests = [] === $inputRequests ? null : $inputRequests;
85:
86: if (null !== $inputRequests) {
87: Assert::that($inputRequests)->keys()->isIntOrNonEmptyString('each "result.inputRequests" key must be an int or non-empty string.');
88: Assert::that($inputRequests)
89: ->values()
90: ->isInstanceOf(InputRequest::class, 'each "result.inputRequests" entry must be an InputRequest, {type} given.')
91: ;
92: }
93:
94: $this->assertStatusPayload($status, $result, $error, $inputRequests);
95:
96: $this->inputRequests = $inputRequests;
97:
98: parent::__construct(meta: $meta);
99: }
100:
101: #[\Override]
102: public static function fromArray(array $data): static
103: {
104: Assert::that($data)->hasOffset('taskId', '"result" is missing the required "taskId" key.');
105: $taskId = $data['taskId'];
106: Assert::that($taskId)->isNonEmptyString('"result.taskId" must be a non-empty string, {type} given.');
107:
108: Assert::that($data)->hasOffset('status', '"result" is missing the required "status" key.');
109: Assert::that($data['status'])->isOneOf(array_column(TaskStatus::cases(), 'value'), '"result.status" must be one of {choices}, {value} given.');
110: $status = TaskStatus::from($data['status']);
111:
112: Assert::that($data)->hasOffset('createdAt', '"result" is missing the required "createdAt" key.');
113: $createdAt = $data['createdAt'];
114: Assert::that($createdAt)->isNonEmptyString('"result.createdAt" must be a non-empty string, {type} given.');
115:
116: Assert::that($data)->hasOffset('lastUpdatedAt', '"result" is missing the required "lastUpdatedAt" key.');
117: $lastUpdatedAt = $data['lastUpdatedAt'];
118: Assert::that($lastUpdatedAt)->isNonEmptyString('"result.lastUpdatedAt" must be a non-empty string, {type} given.');
119:
120: Assert::that($data)->hasOffset('ttlMs', '"result" is missing the required "ttlMs" key.');
121: $ttlMs = $data['ttlMs'];
122: Assert::that($ttlMs)->nullOr()->isPositiveInt('"result.ttlMs" must be null or a positive integer, {value} given.');
123:
124: $result = null;
125:
126: if (\array_key_exists('result', $data)) {
127: Assert::that($data['result'])
128: ->isArray('"result.result" must be an object, {type} given.')
129: ->isMap('"result.result" must be a string-keyed object.')
130: ;
131: $result = $data['result'];
132: }
133:
134: $error = null;
135:
136: if (\array_key_exists('error', $data)) {
137: Assert::that($data['error'])
138: ->isArray('"result.error" must be an object, {type} given.')
139: ->isMap('"result.error" must be a string-keyed object.')
140: ;
141: $error = $data['error'];
142: }
143:
144: $inputRequests = null;
145:
146: if (\array_key_exists('inputRequests', $data)) {
147: Assert::that($data['inputRequests'])
148: ->isArray('"result.inputRequests" must be an object, {type} given.')
149: ->keys()->isIntOrNonEmptyString('each "result.inputRequests" key must be an int or non-empty string.')
150: ;
151: Assert::that($data['inputRequests'])
152: ->values()
153: ->isArray('each "result.inputRequests" entry must be an object, {type} given.')
154: ->isMap('each "result.inputRequests" entry must be a string-keyed object.')
155: ;
156: $inputRequests = array_map(InputRequestDispatcher::decode(...), $data['inputRequests']);
157: }
158:
159: $statusMessage = null;
160:
161: if (\array_key_exists('statusMessage', $data)) {
162: $statusMessage = $data['statusMessage'];
163: Assert::that($statusMessage)->isNonEmptyString('"result.statusMessage" must be a non-empty string, {type} given.');
164: }
165:
166: $pollIntervalMs = null;
167:
168: if (\array_key_exists('pollIntervalMs', $data)) {
169: $pollIntervalMs = $data['pollIntervalMs'];
170: Assert::that($pollIntervalMs)->isPositiveInt('"result.pollIntervalMs" must be a positive integer, {value} given.');
171: }
172:
173: $meta = new GenericResultMetaObject();
174:
175: if (\array_key_exists('_meta', $data)) {
176: Assert::that($data['_meta'])
177: ->isArray('"result._meta" must be an object, {type} given.')
178: ->not()->isNonEmptyList('"result._meta" must be a string-keyed object.')
179: ;
180: $meta = GenericResultMetaObject::fromArray($data['_meta']);
181: }
182:
183: return new self(
184: taskId: $taskId,
185: status: $status,
186: createdAt: $createdAt,
187: lastUpdatedAt: $lastUpdatedAt,
188: ttlMs: $ttlMs,
189: result: $result,
190: error: $error,
191: inputRequests: $inputRequests,
192: statusMessage: $statusMessage,
193: pollIntervalMs: $pollIntervalMs,
194: meta: $meta,
195: );
196: }
197:
198: #[\Override]
199: public function toArray(): array
200: {
201: $data = [];
202: $meta = $this->meta->toArray();
203:
204: if ([] !== $meta) {
205: $data['_meta'] = $meta;
206: }
207:
208: $data['resultType'] = self::getResultType();
209: $data['taskId'] = $this->taskId;
210: $data['status'] = $this->status->value;
211: $data['createdAt'] = $this->createdAt;
212: $data['lastUpdatedAt'] = $this->lastUpdatedAt;
213: $data['ttlMs'] = $this->ttlMs;
214:
215: if (null !== $this->result) {
216: $data['result'] = $this->result;
217: }
218:
219: if (null !== $this->error) {
220: $data['error'] = $this->error;
221: }
222:
223: if (null !== $this->inputRequests) {
224: $data['inputRequests'] = array_map(
225: static fn(InputRequest $request): array => $request->toArray(),
226: $this->inputRequests,
227: );
228: }
229:
230: if (null !== $this->statusMessage) {
231: $data['statusMessage'] = $this->statusMessage;
232: }
233:
234: if (null !== $this->pollIntervalMs) {
235: $data['pollIntervalMs'] = $this->pollIntervalMs;
236: }
237:
238: return $data;
239: }
240:
241: #[\Override]
242: public function jsonSerialize(): array
243: {
244: $data = $this->toArray();
245:
246: // Both slots carry an object, so a list-shaped one must not encode as an array.
247: if (null !== $this->result && array_is_list($this->result)) {
248: $data['result'] = (object) $this->result;
249: }
250:
251: if (null !== $this->error && array_is_list($this->error)) {
252: $data['error'] = (object) $this->error;
253: }
254:
255: if (null !== $this->inputRequests) {
256: $data['inputRequests'] = array_map(
257: static fn(InputRequest $request): array|\stdClass => $request->jsonSerialize(),
258: $this->inputRequests,
259: );
260:
261: if (array_is_list($this->inputRequests)) {
262: $data['inputRequests'] = (object) $data['inputRequests'];
263: }
264: }
265:
266: return $data;
267: }
268:
269: #[\Override]
270: public function rebuildWithMeta(ResultMetaObject $meta): static
271: {
272: return new self(
273: taskId: $this->taskId,
274: status: $this->status,
275: createdAt: $this->createdAt,
276: lastUpdatedAt: $this->lastUpdatedAt,
277: ttlMs: $this->ttlMs,
278: result: $this->result,
279: error: $this->error,
280: inputRequests: $this->inputRequests,
281: statusMessage: $this->statusMessage,
282: pollIntervalMs: $this->pollIntervalMs,
283: meta: $meta,
284: );
285: }
286:
287: #[\Override]
288: protected function getResultType(): string
289: {
290: return ResultType::Complete->value;
291: }
292:
293: /**
294: * @param null|array<array-key, mixed> $result
295: * @param null|array<array-key, mixed> $error
296: * @param null|array<int|non-empty-string, InputRequest> $inputRequests
297: */
298: private function assertStatusPayload(TaskStatus $status, ?array $result, ?array $error, ?array $inputRequests): void
299: {
300: match ($status) {
301: TaskStatus::Completed => null !== $result && null === $error && null === $inputRequests
302: ? null
303: : throw new \InvalidArgumentException('a completed "result" must carry "result" and neither "error" nor "inputRequests".'),
304: TaskStatus::Failed => null !== $error && null === $result && null === $inputRequests
305: ? null
306: : throw new \InvalidArgumentException('a failed "result" must carry "error" and neither "result" nor "inputRequests".'),
307: TaskStatus::InputRequired => null !== $inputRequests && null === $result && null === $error
308: ? null
309: : throw new \InvalidArgumentException('an input_required "result" must carry "inputRequests" and neither "result" nor "error".'),
310: TaskStatus::Working, TaskStatus::Cancelled => null === $result && null === $error && null === $inputRequests
311: ? null
312: : throw new \InvalidArgumentException(\sprintf('a %s "result" must carry none of "result", "error", or "inputRequests".', $status->value)),
313: };
314: }
315: }
316: