The metadata. * @since 1.4.0 */ public static function authorization_server(): array { return [ // Required fields. The issuer is a path, not the bare domain // (see issuer_url()); the endpoints are their own absolute URLs. 'issuer' => self::issuer_url(), 'authorization_endpoint' => self::url( 'oauth/authorize' ), 'token_endpoint' => self::rest_url( Plugin::rest_namespace() . '/oauth/token' ), 'registration_endpoint' => self::rest_url( Plugin::rest_namespace() . '/oauth/register' ), /* * `jwks_uri` is optional under RFC 8414, but a conforming client * validates the metadata before it authenticates and some — Claude * Code's MCP SDK among them — require it to be a string. Omitting it * fails discovery outright. It is a mid-path REST URL, not a * `.well-known` path, so hosts that intercept a root `/.well-known/` * leave it alone (see issuer_url()). */ 'jwks_uri' => self::rest_url( Plugin::rest_namespace() . '/oauth/jwks' ), // Recommended fields. 'response_types_supported' => [ 'code' ], 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ], /* * `none` is not padding for completeness. Every loopback/native * client — RFC 8252, which is every local MCP bridge — is registered * as a public client with exactly this auth method; see * {@see \Albert\OAuth\Endpoints\ClientRegistration::register()}. * Leaving it out states that the server will not accept what the * server itself just issued, and a conforming client believes the * metadata over its own credentials. */ 'token_endpoint_auth_methods_supported' => [ 'client_secret_post', 'client_secret_basic', 'none' ], 'code_challenge_methods_supported' => [ 'S256' ], // Optional but useful fields. 'scopes_supported' => [ 'default' ], /* * Required by OIDC Discovery, not RFC 8414: where a host answers the root * `/.well-known/` (SiteGround), Claude Code validates the * `openid-configuration` copy as OIDC metadata. No ID tokens are issued, * hence no `openid` scope. */ 'subject_types_supported' => [ 'public' ], 'id_token_signing_alg_values_supported' => [ Jwks::ALGORITHM ], ]; } /** * The RFC 9728 Protected Resource Metadata document. * * One builder for both callers (the `.well-known` route and the REST route), so * they cannot drift. `authorization_servers` must be an issuer identifier * (RFC 9728 §7.6), matching the metadata's own `issuer`, or a client's RFC 8414 * lookup goes to the wrong URL and fails validation. * * @return array The metadata. * @since 1.4.1 */ public static function protected_resource(): array { return [ 'resource' => self::rest_url( Plugin::rest_namespace() . '/mcp' ), 'authorization_servers' => [ self::issuer_url() ], 'scopes_supported' => [ 'default' ], ]; } /** * The issuer identifier: a path, not the bare domain. * * A path issuer makes a client's RFC 8414 §3.1 discovery fall through to * `/.well-known/openid-configuration`, where `.well-known` sits mid-path. * Hosts that intercept only a *root* `/.well-known/` (SiteGround, Servebolt) leave * that alone, so it reaches WordPress and needs no files. Endpoints are their own * URLs, not derived from this. * * @return string The issuer identifier. * @since 1.4.1 */ public static function issuer_url(): string { return self::rest_url( Plugin::rest_namespace() . '/oauth' ); } /** * The base URL for OAuth endpoints. * * The external URL override when one is set and valid, otherwise `home_url()`. * Delegates so the override filter is read, cached and diagnosed in one place. * * @return string The base URL. * @since 1.4.0 */ public static function base_url(): string { return McpServer::get_base_url(); } /** * A REST URL built on the current base URL. * * Deliberately not `rest_url()`: that resolves against `home_url()` and * would ignore the external URL an MCP client was told to use. * * @param string $path The REST route path. * * @return string The full REST URL. * @since 1.4.0 */ public static function rest_url( string $path ): string { return self::url( rest_get_url_prefix() . '/' . ltrim( $path, '/' ) ); } /** * A URL on the current base URL, routed the way this site's permalinks route it. * * Permalinks containing `/index.php/` usually mean the server has no rewrite * rules, so only paths behind `index.php` reach WordPress and a bare * `/wp-json/` is the web server's 404. Plain permalinks are unsupported: they * route no path at all, and the issuer cannot be a query string. The * Dashboard flags them. * * @param string $path Path relative to the site root. * * @return string The full URL. * @since 1.4.2 */ public static function url( string $path ): string { global $wp_rewrite; $index = $wp_rewrite instanceof \WP_Rewrite && $wp_rewrite->using_index_permalinks() ? '/' . $wp_rewrite->index : ''; return self::base_url() . $index . '/' . ltrim( $path, '/' ); } }