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. It signs
20: * without encrypting, so a state may hold a continuation marker and never a secret.
21: *
22: * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
23: */
24: final readonly class RequestStateSigner
25: {
26: /**
27: * Separates the payload from its signature, on the last occurrence since a hexadecimal
28: * signature never contains it.
29: */
30: private const string SEPARATOR = '.';
31:
32: private const int SECRET_BYTES = 32;
33:
34: public function __construct(
35: private string $secret,
36: private string $algorithm = 'sha256',
37: ) {
38: Assert::that($secret)->isNonEmptyString('The request-state signing secret must be a non-empty string.');
39: Assert::that($algorithm)->isOneOf(
40: hash_hmac_algos(),
41: \sprintf('The request-state signing algorithm "%s" is not available.', $algorithm),
42: );
43: }
44:
45: /**
46: * A signing key drawn from the system's random source, for a server that mints no state
47: * beyond the lifetime of its own process.
48: */
49: public static function generate(): self
50: {
51: return new self(bin2hex(random_bytes(self::SECRET_BYTES)));
52: }
53:
54: /**
55: * Mints a state, bound to `$binding` when one is given.
56: *
57: * Pass whatever identifies the caller entitled to resume: an unbound state is replayable by any caller.
58: */
59: public function sign(string $payload, string $binding = ''): string
60: {
61: return $payload.self::SEPARATOR.hash_hmac($this->algorithm, $this->bind($binding, $payload), $this->secret);
62: }
63:
64: /**
65: * The payload a state carries, or null when its signature does not hold, meaning this server did
66: * not mint it or minted it for a different `$binding`.
67: */
68: public function verify(string $state, string $binding = ''): ?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, $binding), $state) ? $payload : null;
79: }
80:
81: /**
82: * Length-prefixes the binding, so no two binding-and-payload pairs share a signing input.
83: */
84: private function bind(string $binding, string $payload): string
85: {
86: return \strlen($binding).self::SEPARATOR.$binding.$payload;
87: }
88: }
89: