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\Core\JsonRpc;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Core\Exception\AbstractJsonRpcProtocolException;
18: use Nexus\Mcp\Core\Exception\InvalidParamsException;
19: use Nexus\Mcp\Core\Exception\InvalidRequestException;
20: use Nexus\Mcp\Core\Exception\MethodMisroutedException;
21: use Nexus\Mcp\Core\Exception\MethodNotFoundException;
22: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcErrorResponse;
23: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcMessage;
24: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcNotification;
25: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcRequest;
26: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcResultResponse;
27: use Nexus\Mcp\Core\Schema\RequestId;
28: use Nexus\Mcp\Core\Schema\Result;
29:
30: /**
31: * Parses decoded JSON-RPC envelopes into concrete message objects.
32: *
33: * @see https://modelcontextprotocol.io/specification/2025-11-25/basic
34: */
35: final class JsonRpcMessageParser
36: {
37: /**
38: * @var array<non-empty-string, class-string<JsonRpcRequest<non-empty-string>>>
39: */
40: private readonly array $requests;
41:
42: /**
43: * @var array<non-empty-string, class-string<JsonRpcNotification<non-empty-string>>>
44: */
45: private readonly array $notifications;
46:
47: /**
48: * @param array<non-empty-string, class-string<JsonRpcRequest<non-empty-string>>> $requests Merged over `JsonRpcMethodRegistry::requests()` with caller precedence.
49: * @param array<non-empty-string, class-string<JsonRpcNotification<non-empty-string>>> $notifications Merged over `JsonRpcMethodRegistry::notifications()` with caller precedence.
50: */
51: public function __construct(array $requests = [], array $notifications = [])
52: {
53: $this->requests = [...JsonRpcMethodRegistry::requests(), ...$requests];
54: $this->notifications = [...JsonRpcMethodRegistry::notifications(), ...$notifications];
55: }
56:
57: /**
58: * @template T of Result = Result
59: *
60: * @param array<string, mixed> $message Decoded JSON-RPC envelope
61: * @param null|class-string<T> $result When null, a success response envelope yields an `UnparsedResultEnvelope`
62: * carrying the raw payload. When supplied, it is decoded into `JsonRpcResultResponse<T>`.
63: *
64: * @return ($result is null
65: * ? JsonRpcErrorResponse|JsonRpcNotification<non-empty-string>|JsonRpcRequest<non-empty-string>|UnparsedResultEnvelope
66: * : JsonRpcErrorResponse|JsonRpcNotification<non-empty-string>|JsonRpcRequest<non-empty-string>|JsonRpcResultResponse<T>)
67: *
68: * @throws AbstractJsonRpcProtocolException
69: */
70: public function parse(array $message, ?string $result = null): JsonRpcMessage|UnparsedResultEnvelope
71: {
72: self::assertJsonRpcVersion($message);
73:
74: if (\array_key_exists('error', $message)) {
75: try {
76: return JsonRpcErrorResponse::fromArray($message);
77: } catch (\InvalidArgumentException $e) {
78: throw new InvalidRequestException(
79: self::extractRequestId($message),
80: \sprintf('Invalid error response: %s', $e->getMessage()),
81: );
82: }
83: }
84:
85: if (\array_key_exists('result', $message)) {
86: try {
87: Assert::that($message)->hasOffset('id', 'Success response must carry an "id".');
88: Assert::that($message['id'])->isArrayKey('Response "id" must be an int or string, {type} given.');
89: $id = new RequestId($message['id']);
90: } catch (\InvalidArgumentException $e) {
91: throw new InvalidRequestException(null, $e->getMessage());
92: }
93:
94: if (null === $result) {
95: return new UnparsedResultEnvelope($id, $message['result']);
96: }
97:
98: try {
99: Assert::that($message['result'])
100: ->isArray('Success response "result" must be an object, {type} given.')
101: ->isMap('Success response "result" must be a string-keyed object.')
102: ;
103: } catch (\InvalidArgumentException $e) {
104: throw new InvalidRequestException($id, $e->getMessage());
105: }
106:
107: try {
108: $typed = $result::fromArray($message['result']);
109: } catch (\InvalidArgumentException $e) {
110: throw new InvalidRequestException($id, \sprintf('Invalid %s payload: %s', $result, $e->getMessage()));
111: }
112:
113: return new JsonRpcResultResponse($id, $typed);
114: }
115:
116: try {
117: Assert::that($message)->hasOffset('method', 'JSON-RPC envelope must carry a "method" (request or notification), an "error" (error response), or a "result" (success response).');
118: Assert::that($message['method'])->isNonEmptyString('JSON-RPC envelope "method" must be a non-empty string, {type} given.');
119: } catch (\InvalidArgumentException $e) {
120: throw new InvalidRequestException(self::extractRequestId($message), $e->getMessage());
121: }
122:
123: $method = $message['method'];
124:
125: if (\array_key_exists('id', $message)) {
126: try {
127: Assert::that($message['id'])->isArrayKey('Request "id" must be an int or string, {type} given.');
128: $id = new RequestId($message['id']);
129: } catch (\InvalidArgumentException $e) {
130: throw new InvalidRequestException(null, $e->getMessage());
131: }
132:
133: $class = $this->requests[$method] ?? null;
134:
135: if (null === $class) {
136: if (\array_key_exists($method, $this->notifications)) {
137: throw new MethodMisroutedException(
138: $method,
139: expectedShape: 'notification',
140: receivedShape: 'request',
141: requestId: $id,
142: );
143: }
144:
145: throw new MethodNotFoundException($method, $id);
146: }
147:
148: try {
149: return $class::fromArray($message);
150: } catch (\InvalidArgumentException $e) {
151: throw new InvalidParamsException(
152: $id,
153: \sprintf('Invalid "%s" request: %s', SafeDisplay::sanitise($method), $e->getMessage()),
154: );
155: }
156: }
157:
158: $class = $this->notifications[$method] ?? null;
159:
160: if (null === $class) {
161: if (\array_key_exists($method, $this->requests)) {
162: throw new MethodMisroutedException(
163: $method,
164: expectedShape: 'request',
165: receivedShape: 'notification',
166: );
167: }
168:
169: throw new MethodNotFoundException($method);
170: }
171:
172: try {
173: return $class::fromArray($message);
174: } catch (\InvalidArgumentException $e) {
175: throw new InvalidParamsException(
176: null,
177: \sprintf('Invalid "%s" notification: %s', SafeDisplay::sanitise($method), $e->getMessage()),
178: );
179: }
180: }
181:
182: /**
183: * @param array<string, mixed> $message
184: */
185: private static function assertJsonRpcVersion(array $message): void
186: {
187: $version = $message['jsonrpc'] ?? null;
188:
189: if (JsonRpcMessage::JSONRPC_VERSION !== $version) {
190: throw new InvalidRequestException(
191: self::extractRequestId($message),
192: \sprintf(
193: 'Invalid JSON-RPC version: expected "%s", got %s.',
194: JsonRpcMessage::JSONRPC_VERSION,
195: null === $version ? 'null' : SafeDisplay::sanitise(var_export($version, true)),
196: ),
197: );
198: }
199: }
200:
201: /**
202: * @param array<string, mixed> $message
203: */
204: private static function extractRequestId(array $message): ?RequestId
205: {
206: $id = $message['id'] ?? null;
207:
208: if (! \is_int($id) && ! \is_string($id)) {
209: return null;
210: }
211:
212: try {
213: return new RequestId($id);
214: } catch (\InvalidArgumentException) {
215: return null;
216: }
217: }
218: }
219: