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\Server;
15:
16: use Nexus\Assert\Assert;
17:
18: /**
19: * Mints and checks the `requestState` an `InputRequiredResult` carries across a round trip.
20: *
21: * The payload travels in the clear and is signed, not encrypted, so a state may hold a
22: * continuation marker but never a secret.
23: *
24: * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
25: */
26: final readonly class RequestStateSigner
27: {
28: /**
29: * Separates the payload from its signature. Hexadecimal signatures never contain it, so the
30: * last occurrence always splits the two however many the payload holds.
31: */
32: private const string SEPARATOR = '.';
33:
34: /**
35: * Entropy behind a generated signing key, in bytes.
36: */
37: private const int SECRET_BYTES = 32;
38:
39: /**
40: * @param string $secret Signing key, held only by the server that mints the state
41: */
42: public function __construct(private string $secret, private string $algorithm = 'sha256')
43: {
44: Assert::that($secret)->isNonEmptyString('The request-state signing secret must be a non-empty string.');
45: Assert::that(\in_array($algorithm, hash_hmac_algos(), true))->isTrue(
46: \sprintf('The request-state signing algorithm "%s" is not available.', $algorithm),
47: );
48: }
49:
50: /**
51: * A signing key drawn from the system's random source, for a server that mints no state
52: * beyond the lifetime of its own process.
53: */
54: public static function generate(): self
55: {
56: return new self(bin2hex(random_bytes(self::SECRET_BYTES)));
57: }
58:
59: public function sign(string $payload): string
60: {
61: return $payload.self::SEPARATOR.hash_hmac($this->algorithm, $payload, $this->secret);
62: }
63:
64: /**
65: * The payload a state carries, or null when its signature does not hold. A handler that
66: * receives null has been handed a state this server did not mint.
67: */
68: public function verify(string $state): ?string
69: {
70: $split = strrpos($state, self::SEPARATOR);
71:
72: if (false === $split) {
73: return null;
74: }
75:
76: $payload = substr($state, 0, $split);
77:
78: return hash_equals($this->sign($payload), $state) ? $payload : null;
79: }
80: }
81: