Skip to content

Commit 4c23e16

Browse files
committed
[Server] Keep issuer and client bound when a resolver names the requestState subject
1 parent 1c58b6f commit 4c23e16

4 files changed

Lines changed: 32 additions & 29 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
2626
* Add `Psr6SessionStore` and `Psr6NotificationBus`, which work like `Psr16SessionStore` and `Psr16NotificationBus` over a PSR-6 cache pool. `Builder::setDiscovery()` also accepts a PSR-6 pool as `$cache`.
2727
* Fix a nullable tool parameter with a default (e.g. `?string $category = 'books'`) receiving the default when the client explicitly sends `null`: `ReferenceHandler` now passes the `null` through.
2828
* Fix a `subscriptions/listen` stream that can carry nothing (the server announces none of the requested types, or the client asks for none) being held open for the full subscription lifetime: it is acknowledged with an empty `notifications` filter and closed gracefully right away, instead of pinning a PHP-FPM worker. Adds `NotificationFilter::isEmpty()`.
29-
* Add a `binder` argument to `Builder::setRequestState()` binding the modern era's `requestState` to the request it was minted for, as the 2026-07-28 specification recommends: the binder's value is covered by the state's MAC, and a state echoed on a request that binds to another value is refused like a forged one. `OriginatingRequestBinder` binds the authenticated principal, the method, the tool, prompt or resource, and a digest of the arguments; a token without a subject fails closed, and a `principal` resolver identifies users by another claim. `RequestStateBinderInterface` takes custom bindings. Without a binder, nothing changes.
29+
* Add a `binder` argument to `Builder::setRequestState()` binding the modern era's `requestState` to the request it was minted for, as the 2026-07-28 specification recommends: the binder's value is covered by the state's MAC, and a state echoed on a request that binds to another value is refused like a forged one. `OriginatingRequestBinder` binds the authenticated principal, the method, the tool, prompt or resource, and a digest of the arguments; a token without a subject fails closed, and a `subject` resolver identifies users by another claim. `RequestStateBinderInterface` takes custom bindings. Without a binder, nothing changes.
3030

3131
### Client
3232

‎docs/handlers/input-required.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,13 @@ A token without a subject (`sub`) identifies no user, so the binder cannot tell
125125
one client apart. It fails closed: such a request binds to a value of its own, and a state it
126126
mints is refused on the retry. If your authorization server identifies users by another claim,
127127
pass a resolver that returns a value stable across a token refresh, or `null` for a token that
128-
identifies nobody:
128+
identifies nobody. The issuer and client stay bound either way:
129129

130130
```php
131131
use Mcp\Server\Authorization\AccessToken;
132132

133133
new OriginatingRequestBinder(
134-
principal: static fn (AccessToken $token): ?string => \is_string($oid = $token->getClaim('oid')) ? $oid : null,
134+
subject: static fn (AccessToken $token): ?string => \is_string($oid = $token->getClaim('oid')) ? $oid : null,
135135
)
136136
```
137137

‎src/Server/Stateless/OriginatingRequestBinder.php‎

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@
3333
*
3434
* A token without a subject identifies no user: tokens of different users of
3535
* one client would share a principal. Such a request binds to a value of its
36-
* own, so the states it mints never verify. Pass a `$principal` resolver when
37-
* the authorization server identifies users by another claim.
36+
* own, so the states it mints never verify. Pass a `$subject` resolver when
37+
* the authorization server identifies users by another claim; the issuer
38+
* and client stay part of the principal either way.
3839
*
3940
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow
4041
*
@@ -46,12 +47,12 @@ final class OriginatingRequestBinder implements RequestStateBinderInterface
4647
private const ENCODE_DEPTH = 3 * 512;
4748

4849
/**
49-
* @param (\Closure(AccessToken): ?string)|null $principal identifies the user and client a token was issued
50-
* for, stable across a token refresh; null when the token
51-
* identifies nobody. Defaults to issuer, client and subject.
50+
* @param (\Closure(AccessToken): ?string)|null $subject identifies the user a token was issued for, stable
51+
* across a token refresh; null when the token identifies
52+
* nobody. Defaults to the `sub` claim.
5253
*/
5354
public function __construct(
54-
private readonly ?\Closure $principal = null,
55+
private readonly ?\Closure $subject = null,
5556
) {
5657
}
5758

@@ -73,34 +74,24 @@ public function bind(Request $request, ?AccessToken $accessToken): string
7374
}
7475

7576
/**
76-
* @return array{identified: string}|array{unidentified: string}
77+
* @return array{identified: array{iss: ?string, client: ?string, sub: string}}|array{unidentified: string}
7778
*/
7879
private function principal(AccessToken $accessToken): array
7980
{
80-
$principal = null === $this->principal ? self::defaultPrincipal($accessToken) : ($this->principal)($accessToken);
81-
82-
if (null === $principal) {
83-
return ['unidentified' => bin2hex(random_bytes(16))];
84-
}
85-
86-
return ['identified' => $principal];
87-
}
88-
89-
private static function defaultPrincipal(AccessToken $accessToken): ?string
90-
{
91-
$subject = $accessToken->getSubject();
81+
$subject = null === $this->subject ? $accessToken->getSubject() : ($this->subject)($accessToken);
9282

9383
if (null === $subject) {
94-
return null;
84+
return ['unidentified' => bin2hex(random_bytes(16))];
9585
}
9686

9787
$issuer = $accessToken->getClaim('iss');
9888

99-
return self::encode([
89+
// Not up to the resolver: a state stays with the issuer and client it was minted for.
90+
return ['identified' => [
10091
'iss' => \is_string($issuer) ? $issuer : null,
10192
'client' => $accessToken->getClientId(),
10293
'sub' => $subject,
103-
]);
94+
]];
10495
}
10596

10697
private static function encode(mixed $value): string

‎tests/Unit/Server/Stateless/OriginatingRequestBinderTest.php‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ public function testTokenWithoutSubjectBindsToAValueOfItsOwn(): void
133133
$this->assertNotSame($binder->bind(self::call(), $token), $binder->bind(self::call(), $token));
134134
}
135135

136-
#[TestDox('a principal resolver identifies users by another claim')]
137-
public function testPrincipalResolverIdentifiesUsersByAnotherClaim(): void
136+
#[TestDox('a subject resolver identifies users by another claim')]
137+
public function testSubjectResolverIdentifiesUsersByAnotherClaim(): void
138138
{
139139
$binder = new OriginatingRequestBinder(
140140
static fn (AccessToken $token): ?string => \is_string($oid = $token->getClaim('oid')) ? $oid : null,
@@ -146,8 +146,20 @@ public function testPrincipalResolverIdentifiesUsersByAnotherClaim(): void
146146
$this->assertNotSame($binder->bind(self::call(), $token('alice', 'a')), $binder->bind(self::call(), null));
147147
}
148148

149-
#[TestDox('a principal resolver that identifies nobody fails closed')]
150-
public function testPrincipalResolverReturningNullFailsClosed(): void
149+
#[TestDox('a subject resolver keeps the issuer and client in the principal')]
150+
public function testSubjectResolverKeepsIssuerAndClient(): void
151+
{
152+
$binder = new OriginatingRequestBinder(
153+
static fn (AccessToken $token): ?string => \is_string($oid = $token->getClaim('oid')) ? $oid : null,
154+
);
155+
$token = static fn (string $issuer, string $clientId): AccessToken => new AccessToken(['read'], ['iss' => $issuer, 'client_id' => $clientId, 'oid' => 'alice']);
156+
157+
$this->assertNotSame($binder->bind(self::call(), $token('https://auth.example.com', 'app')), $binder->bind(self::call(), $token('https://auth.example.com', 'other-app')));
158+
$this->assertNotSame($binder->bind(self::call(), $token('https://auth.example.com', 'app')), $binder->bind(self::call(), $token('https://other.example.com', 'app')));
159+
}
160+
161+
#[TestDox('a subject resolver that identifies nobody fails closed')]
162+
public function testSubjectResolverReturningNullFailsClosed(): void
151163
{
152164
$binder = new OriginatingRequestBinder(static fn (AccessToken $token): ?string => null);
153165

0 commit comments

Comments
 (0)