fix: reset request context after the MCP request, resolve client name

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-25 21:43:07 +02:00
co-authored by Claude Opus 5.5
parent f63d35c2f8
commit 720d0891c4
3 changed files with 147 additions and 23 deletions
+71 -10
View File
@@ -4,28 +4,32 @@ declare(strict_types=1);
namespace LIT\MCP\Server;
use Throwable;
use WP_REST_Request;
use WPMedia\MCP\OAuth\Auth\JWT;
use WPMedia\MCP\OAuth\Auth\SecretManager;
/**
* Tracks whether the current REST dispatch is serving our OAuth-protected MCP
* server route (`/mcp/mcp-oauth-server`).
* server route (`/mcp/mcp-oauth-server`) and which OAuth client made it.
*
* Abilities must never run outside that route – not through mcp-adapter's
* (disabled) default server, not through a differently configured server, and
* not through any unrelated REST request. WordPress fires
* Abilities must never run outside that route. WordPress fires
* `rest_request_before_callbacks` for every matched REST route before any
* permission_callback runs (see WP_REST_Server::dispatch()), so by the time an
* ability's permission_callback executes – itself only reachable from inside
* the MCP route's own callback – the flag below is already accurate for the
* current request.
* permission_callback runs and `rest_request_after_callbacks` right after the
* route callback returned (WP_REST_Server::respond_to_request()). The flag is
* set in the first and cleared in the second, so persistent PHP workers or a
* later `rest_do_request()` in the same process never inherit it.
*/
final class RequestContext
{
private static bool $isOAuthServerRequest = false;
private static ?string $bearerToken = null;
private static ?string $clientName = null;
public function register(): void
{
add_filter('rest_request_before_callbacks', [self::class, 'recordRoute'], 10, 3);
add_filter('rest_request_after_callbacks', [self::class, 'clearRoute'], 10, 3);
}
/**
@@ -37,6 +41,25 @@ final class RequestContext
{
if ($request->get_route() === self::route()) {
self::$isOAuthServerRequest = true;
self::$clientName = null;
$header = $request->get_header('authorization');
self::$bearerToken = is_string($header) && str_starts_with($header, 'Bearer ')
? substr($header, 7)
: null;
}
return $response;
}
/**
* @param mixed $response
* @param array<string, mixed> $handler
* @return mixed
*/
public static function clearRoute(mixed $response, array $handler, WP_REST_Request $request): mixed
{
if ($request->get_route() === self::route()) {
self::reset();
}
return $response;
@@ -48,11 +71,49 @@ final class RequestContext
}
/**
* Test-only: resets (or forces) the recorded state between unit tests.
* Name of the connected OAuth client (the Application Password mcp-oauth created
* for it), falling back to the token's client_id; '' when unknown.
*/
public static function reset(bool $isOAuthServerRequest = false): void
public static function clientName(): string
{
if (self::$clientName === null) {
self::$clientName = self::$bearerToken === null ? '' : self::resolveClientName(self::$bearerToken);
}
return self::$clientName;
}
/**
* Resets (or forces) the recorded state – after each request and between unit tests.
*/
public static function reset(bool $isOAuthServerRequest = false, ?string $clientName = null): void
{
self::$isOAuthServerRequest = $isOAuthServerRequest;
self::$bearerToken = null;
self::$clientName = $clientName;
}
private static function resolveClientName(string $token): string
{
try {
$claims = JWT::decode($token, SecretManager::get_secret());
if (! is_array($claims)) {
return '';
}
$userId = (int) ($claims['sub'] ?? 0);
$uuid = (string) ($claims['app_pass_id'] ?? '');
if ($userId > 0 && $uuid !== '' && class_exists('WP_Application_Passwords')) {
$item = \WP_Application_Passwords::get_user_application_password($userId, $uuid);
if (is_array($item) && is_string($item['name'] ?? null) && $item['name'] !== '') {
return $item['name'];
}
}
return (string) ($claims['client_id'] ?? '');
} catch (Throwable) {
return '';
}
}
private static function route(): string