on all SSE/message calls * 5. Auth::validate() already handles Bearer — no further changes needed * * Claude.ai connector setup: * URL: https://yoursite.com/wp-json/asktam-mcp/v1/sse * OAuth Client ID: your mcp_xxx key * Token field: your mcp_xxx key (used as client_secret) */ class OAuthServer { public function registerRoutes(): void { // Token endpoint — Claude.ai POSTs here to exchange client credentials for Bearer token register_rest_route('asktam-mcp/v1', '/oauth/token', [ 'methods' => 'POST', 'callback' => [$this, 'handleToken'], 'permission_callback' => '__return_true', ]); // Authorization server metadata — Claude.ai fetches this to discover token endpoint register_rest_route('asktam-mcp/v1', '/oauth/metadata', [ 'methods' => 'GET', 'callback' => [$this, 'handleMetadata'], 'permission_callback' => '__return_true', ]); // Also register at /.well-known/oauth-authorization-server for auto-discovery // (Claude.ai checks this path relative to the SSE URL's origin) add_action('rest_api_init', function() { register_rest_route('', '/.well-known/oauth-authorization-server', [ 'methods' => 'GET', 'callback' => [$this, 'handleWellKnown'], 'permission_callback' => '__return_true', ]); }, 20); } /** * POST /wp-json/asktam-mcp/v1/oauth/token * * Accepts: * grant_type=client_credentials * client_id=mcp_xxx * client_secret=mcp_xxx * * Also accepts Basic auth header: Authorization: Basic base64(client_id:client_secret) */ public function handleToken(\WP_REST_Request $request): \WP_REST_Response { // Extract credentials — try body params first, then Basic auth header [$clientId, $clientSecret] = $this->extractCredentials($request); if (empty($clientId) || empty($clientSecret)) { return $this->oauthError('invalid_client', 'Missing client_id or client_secret', 401); } $grantType = $request->get_param('grant_type') ?? 'client_credentials'; if ($grantType !== 'client_credentials') { return $this->oauthError('unsupported_grant_type', 'Only client_credentials grant is supported', 400); } // Validate: client_id must match a valid active key // client_secret must equal client_id (single-key model — the API key IS both) global $wpdb; $table = $wpdb->prefix . 'asktam_mcp_keys'; $row = $wpdb->get_row( $wpdb->prepare("SELECT * FROM {$table} WHERE api_key = %s AND is_active = 1", $clientId), ARRAY_A ); if (!$row) { return $this->oauthError('invalid_client', 'Invalid or inactive client_id', 401); } // Validate secret matches client_id (our simple model) if (!hash_equals($clientId, $clientSecret)) { return $this->oauthError('invalid_client', 'client_secret does not match client_id', 401); } // Issue access token — store as transient, TTL 1 hour // Token IS the api_key itself (stateless — Auth::validate handles it) // This way we don't need a separate token store $accessToken = $clientId; $expiresIn = 3600; // Update last_used $wpdb->update($table, ['last_used' => current_time('mysql')], ['api_key' => $clientId]); return new \WP_REST_Response([ 'access_token' => $accessToken, 'token_type' => 'bearer', 'expires_in' => $expiresIn, 'scope' => $row['scopes'], ], 200); } /** * OAuth Authorization Server Metadata (RFC 8414) * Claude.ai uses this to discover the token endpoint. */ public function handleMetadata(): \WP_REST_Response { $base = rest_url('asktam-mcp/v1'); return new \WP_REST_Response([ 'issuer' => get_site_url(), 'token_endpoint' => $base . '/oauth/token', 'token_endpoint_auth_methods_supported' => ['client_secret_post', 'client_secret_basic'], 'grant_types_supported' => ['client_credentials'], 'response_types_supported' => ['token'], 'scopes_supported' => ['read', 'write', 'admin', 'woocommerce'], ]); } /** * /.well-known/oauth-authorization-server * Standard discovery endpoint — Claude.ai checks this against the SSE URL origin. */ public function handleWellKnown(): \WP_REST_Response { return $this->handleMetadata(); } // ------------------------------------------------------------------------- private function extractCredentials(\WP_REST_Request $request): array { // Try body params first (client_secret_post) $clientId = $request->get_param('client_id'); $clientSecret = $request->get_param('client_secret'); if (!empty($clientId) && !empty($clientSecret)) { return [$clientId, $clientSecret]; } // Try Basic auth (client_secret_basic) $authHeader = $request->get_header('Authorization'); if ($authHeader && str_starts_with($authHeader, 'Basic ')) { $decoded = base64_decode(substr($authHeader, 6)); if ($decoded && str_contains($decoded, ':')) { [$clientId, $clientSecret] = explode(':', $decoded, 2); return [trim($clientId), trim($clientSecret)]; } } return ['', '']; } private function oauthError(string $error, string $description, int $status): \WP_REST_Response { return new \WP_REST_Response([ 'error' => $error, 'error_description' => $description, ], $status); } }