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\SafeDisplay;
23: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcErrorResponse;
24: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcMessage;
25: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcNotification;
26: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcRequest;
27: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcResultResponse;
28: use Nexus\Mcp\Core\Schema\RequestId;
29:
30: /**
31: * Parses decoded JSON-RPC envelopes into concrete message objects.
32: *
33: * @see https://modelcontextprotocol.io/specification/2026-07-28/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 TResponse of JsonRpcResultResponse = JsonRpcResultResponse
59: *
60: * @param array<string, mixed> $message Decoded JSON-RPC envelope
61: * @param null|class-string<TResponse> $response When null, a success response envelope yields an `UnparsedResultEnvelope`
62: * carrying the raw payload. When supplied, the result payload is decoded
63: * into the response envelope `TResponse`.
64: *
65: * @return ($response is null
66: * ? JsonRpcErrorResponse|JsonRpcNotification<non-empty-string>|JsonRpcRequest<non-empty-string>|UnparsedResultEnvelope
67: * : JsonRpcErrorResponse|JsonRpcNotification<non-empty-string>|JsonRpcRequest<non-empty-string>|TResponse)
68: *
69: * @throws AbstractJsonRpcProtocolException
70: */
71: public function parse(array $message, ?string $response = null): JsonRpcMessage|UnparsedResultEnvelope
72: {
73: $this->assertJsonRpcVersion($message);
74:
75: if (\array_key_exists('method', $message) && (\array_key_exists('error', $message) || \array_key_exists('result', $message))) {
76: throw new InvalidRequestException(
77: EnvelopeRequestId::recover($message),
78: 'JSON-RPC envelope must not carry a "method" together with a "result" or an "error".',
79: );
80: }
81:
82: if (\array_key_exists('error', $message)) {
83: try {
84: return JsonRpcErrorResponse::fromArray($message);
85: } catch (\InvalidArgumentException $e) {
86: throw new InvalidRequestException(
87: EnvelopeRequestId::recover($message),
88: \sprintf('Invalid error response: %s', SafeDisplay::sanitiseCause($e->getMessage())),
89: );
90: }
91: }
92:
93: if (\array_key_exists('result', $message)) {
94: try {
95: Assert::that($message)->hasOffset('id', 'missing the required "id" key.');
96: Assert::that($message['id'])->isIntOrNonEmptyString('"id" must be an int or non-empty string, {type} given.');
97: $id = new RequestId(id: $message['id']);
98: } catch (\InvalidArgumentException $e) {
99: throw new InvalidRequestException(null, \sprintf('Invalid success response: %s', SafeDisplay::sanitiseCause($e->getMessage())));
100: }
101:
102: if (null === $response) {
103: return new UnparsedResultEnvelope($id, $message['result']);
104: }
105:
106: try {
107: return $response::fromArray($message);
108: } catch (\InvalidArgumentException $e) {
109: throw new InvalidRequestException($id, \sprintf('Invalid success response: %s', SafeDisplay::sanitiseCause($e->getMessage())));
110: }
111: }
112:
113: try {
114: Assert::that($message)->hasOffset('method', 'JSON-RPC envelope must carry a "method" (request or notification), an "error" (error response), or a "result" (success response).');
115: Assert::that($message['method'])->isNonEmptyString('JSON-RPC envelope "method" must be a non-empty string, {type} given.');
116: } catch (\InvalidArgumentException $e) {
117: throw new InvalidRequestException(EnvelopeRequestId::recover($message), SafeDisplay::sanitiseCause($e->getMessage()));
118: }
119:
120: $method = $message['method'];
121:
122: if (\array_key_exists('id', $message)) {
123: try {
124: Assert::that($message['id'])->isIntOrNonEmptyString('"id" must be an int or non-empty string, {type} given.');
125: $id = new RequestId(id: $message['id']);
126: } catch (\InvalidArgumentException $e) {
127: throw new InvalidRequestException(
128: null,
129: \sprintf('Invalid "%s" request: %s', SafeDisplay::sanitise($method), SafeDisplay::sanitiseCause($e->getMessage())),
130: );
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), SafeDisplay::sanitiseCause($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), SafeDisplay::sanitiseCause($e->getMessage())),
178: );
179: }
180: }
181:
182: /**
183: * @param array<string, mixed> $message
184: */
185: private function assertJsonRpcVersion(array $message): void
186: {
187: $version = $message['jsonrpc'] ?? null;
188:
189: if (JsonRpcMessage::JSONRPC_VERSION !== $version) {
190: $method = $message['method'] ?? null;
191:
192: throw new InvalidRequestException(
193: EnvelopeRequestId::recover($message),
194: \sprintf(
195: 'Invalid JSON-RPC version: expected "%s", got %s%s.',
196: JsonRpcMessage::JSONRPC_VERSION,
197: null === $version ? 'null' : SafeDisplay::sanitise(var_export($version, true)),
198: \is_string($method) && '' !== $method
199: ? \sprintf(' for method %s', SafeDisplay::sanitise(var_export($method, true)))
200: : '',
201: ),
202: );
203: }
204: }
205: }
206: