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