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: use Nexus\Clock\Clock;
18: use Nexus\Clock\SystemClock;
19: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcRequest;
20: use Nexus\Mcp\Core\Schema\RequestParams\CallToolRequestParams;
21: use Nexus\Mcp\Core\Schema\RequestParams\GetPromptRequestParams;
22: use Nexus\Mcp\Core\Schema\RequestParams\ResourceRequestParams;
23: use Nexus\Mcp\Core\Transport\ReceiveContext;
24:
25: /**
26: * Mints and checks the `requestState` carried by an `InputRequiredResult` across a round trip. It signs
27: * without encrypting, so a state may hold a continuation marker and never a secret.
28: *
29: * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
30: */
31: final readonly class RequestStateSigner
32: {
33: public const int DEFAULT_TTL_SECONDS = 600;
34:
35: /**
36: * Separates the payload, the expiry and the signature, on the last occurrences since neither the
37: * expiry nor the hexadecimal signature contains it.
38: */
39: private const string SEPARATOR = '.';
40:
41: private const string BINDING_SEPARATOR = "\0";
42: private const int SECRET_BYTES = 32;
43:
44: /**
45: * @param int<1, max> $ttlSeconds How long a minted state verifies
46: * @param string $binding The binding applied when `sign()` and `verify()` are given none
47: * @param null|\Closure(JsonRpcRequest<non-empty-string>, ReceiveContext): string $policy Derives the binding for a request in `bindFor()`, the authenticated subject, method and target when null
48: */
49: public function __construct(
50: private string $secret,
51: private string $algorithm = 'sha256',
52: private int $ttlSeconds = self::DEFAULT_TTL_SECONDS,
53: private Clock $clock = new SystemClock(),
54: private string $binding = '',
55: private ?\Closure $policy = null,
56: ) {
57: Assert::that($secret)->isNonEmptyString('The request-state signing secret must be a non-empty string.');
58: Assert::that($algorithm)->isOneOf(
59: hash_hmac_algos(),
60: \sprintf('The request-state signing algorithm "%s" is not available.', $algorithm),
61: );
62: Assert::that($ttlSeconds)->isPositiveInt('The request-state TTL must be a positive number of seconds, {value} given.');
63: }
64:
65: /**
66: * A signing key drawn from the system's random source, for a server that mints no state
67: * beyond the lifetime of its own process.
68: *
69: * @param int<1, max> $ttlSeconds
70: */
71: public static function generate(int $ttlSeconds = self::DEFAULT_TTL_SECONDS): self
72: {
73: return new self(bin2hex(random_bytes(self::SECRET_BYTES)), ttlSeconds: $ttlSeconds);
74: }
75:
76: /**
77: * The binding for a request under the default policy: the authenticated subject, the method, the tool
78: * name, prompt name or resource URI it targets, and a digest of its arguments.
79: *
80: * @param JsonRpcRequest<non-empty-string> $request
81: */
82: public static function deriveBinding(JsonRpcRequest $request, ReceiveContext $receiveContext): string
83: {
84: $params = $request->params;
85: [$target, $arguments] = match (true) {
86: $params instanceof CallToolRequestParams, $params instanceof GetPromptRequestParams => [$params->name, $params->arguments],
87: $params instanceof ResourceRequestParams => [$params->uri, null],
88: default => ['', null],
89: };
90:
91: return implode(self::BINDING_SEPARATOR, [
92: $receiveContext->authInfo->subject ?? '',
93: $request::getMethod(),
94: $target,
95: null === $arguments ? '' : hash('sha256', json_encode(self::sortKeys($arguments), \JSON_THROW_ON_ERROR)),
96: ]);
97: }
98:
99: /**
100: * Mints a state, bound to `$binding` when one is given and to this signer's own binding otherwise.
101: *
102: * Pass whatever identifies the caller entitled to resume: an unbound state is replayable by any caller.
103: */
104: public function sign(string $payload, string $binding = ''): string
105: {
106: return $this->seal($payload, $this->clock->now()->getTimestamp() + $this->ttlSeconds, '' === $binding ? $this->binding : $binding);
107: }
108:
109: /**
110: * The payload carried by a state, or null when it has expired or its signature does not hold, meaning
111: * this server did not mint it or minted it for a different `$binding`.
112: */
113: public function verify(string $state, string $binding = ''): ?string
114: {
115: $signatureAt = strrpos($state, self::SEPARATOR);
116:
117: if (false === $signatureAt) {
118: return null;
119: }
120:
121: $body = substr($state, 0, $signatureAt);
122: $expiryAt = strrpos($body, self::SEPARATOR);
123:
124: if (false === $expiryAt) {
125: return null;
126: }
127:
128: $payload = substr($body, 0, $expiryAt);
129: $expires = (int) substr($body, $expiryAt + 1);
130:
131: if (! hash_equals($this->seal($payload, $expires, '' === $binding ? $this->binding : $binding), $state)) {
132: return null;
133: }
134:
135: return $expires < $this->clock->now()->getTimestamp() ? null : $payload;
136: }
137:
138: /**
139: * A signer whose `sign()` and `verify()` default to `$binding`.
140: */
141: public function bindTo(string $binding): self
142: {
143: return new self($this->secret, $this->algorithm, $this->ttlSeconds, $this->clock, $binding, $this->policy);
144: }
145:
146: /**
147: * A signer deriving the binding for a request through `$policy` instead of the default one.
148: *
149: * @param \Closure(JsonRpcRequest<non-empty-string>, ReceiveContext): string $policy
150: */
151: public function bindBy(\Closure $policy): self
152: {
153: return new self($this->secret, $this->algorithm, $this->ttlSeconds, $this->clock, $this->binding, $policy);
154: }
155:
156: /**
157: * A signer bound to `$request` under the policy, so a state it mints resumes that request alone.
158: *
159: * @param JsonRpcRequest<non-empty-string> $request
160: */
161: public function bindFor(JsonRpcRequest $request, ReceiveContext $receiveContext): self
162: {
163: return $this->bindTo(null === $this->policy ? self::deriveBinding($request, $receiveContext) : ($this->policy)($request, $receiveContext));
164: }
165:
166: /**
167: * @param array<array-key, mixed> $arguments
168: *
169: * @return array<array-key, mixed>
170: */
171: private static function sortKeys(array $arguments): array
172: {
173: ksort($arguments);
174:
175: foreach ($arguments as $key => $value) {
176: if (\is_array($value)) {
177: $arguments[$key] = self::sortKeys($value);
178: }
179: }
180:
181: return $arguments;
182: }
183:
184: private function seal(string $payload, int $expires, string $binding): string
185: {
186: $body = $payload.self::SEPARATOR.$expires;
187:
188: return $body.self::SEPARATOR.hash_hmac($this->algorithm, \strlen($binding).self::SEPARATOR.$binding.$body, $this->secret);
189: }
190: }
191: